Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions Editor/Model/ShelfItem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;

namespace Yozolab.Tabstep
{
/// <summary>
/// One object parked on the Tabstep Shelf. Stored as a GlobalObjectId (plus the
/// asset GUID when it is an asset) instead of a raw Object reference, so items
/// survive domain reloads and identify sub-assets and scene objects precisely;
/// scene objects resolve only while their scene is open.
/// </summary>
[Serializable]
class ShelfItem
{
[SerializeField] string _globalId = "";
[SerializeField] string _guid = "";
[SerializeField] string _displayName = "";

[NonSerialized] Object _cached;

ShelfItem() { }

/// <summary>Null when the object cannot be identified (destroyed, not yet persisted...).</summary>
public static ShelfItem ForObject(Object obj)
{
if (obj == null) return null;
var item = new ShelfItem { _displayName = obj.name };
var path = AssetDatabase.GetAssetPath(obj);
if (!string.IsNullOrEmpty(path))
item._guid = AssetDatabase.AssetPathToGUID(path);
var globalId = GlobalObjectId.GetGlobalObjectIdSlow(obj);
// identifierType 0 is the null id — a transient object that could never
// be resolved again has no business on the shelf.
if (globalId.identifierType == 0 && string.IsNullOrEmpty(item._guid)) return null;
item._globalId = globalId.ToString();
item._cached = obj;
return item;
}

/// <summary>Stable identity for dedup and for tracking an item through a drag.</summary>
public string Key => string.IsNullOrEmpty(_globalId) ? _guid : _globalId;

public bool IsAsset => !string.IsNullOrEmpty(_guid);

/// <summary>Project path for assets (feeds DragAndDrop.paths), null for scene objects.</summary>
public string AssetPath
{
get
{
if (!IsAsset) return null;
var path = AssetDatabase.GUIDToAssetPath(_guid);
return string.IsNullOrEmpty(path) ? null : path;
}
}

public string DisplayName
{
get
{
var obj = Resolve();
return obj != null ? obj.name : _displayName;
}
}

/// <summary>The live object, or null when it no longer exists (shown greyed out).</summary>
public Object Resolve()
{
if (_cached != null) return _cached;
if (GlobalObjectId.TryParse(_globalId, out var id))
_cached = GlobalObjectId.GlobalObjectIdentifierToObjectSlow(id);
if (_cached == null && IsAsset)
{
var path = AssetPath;
if (path != null) _cached = AssetDatabase.LoadMainAssetAtPath(path);
}
return _cached;
}
}
}
11 changes: 11 additions & 0 deletions Editor/Model/ShelfItem.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 41 additions & 5 deletions Editor/Model/TabCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,37 +60,73 @@ public void CycleActive(int delta)
_activeIndex = ((_activeIndex + delta) % _tabs.Count + _tabs.Count) % _tabs.Count;
}

/// <summary>Moves a tab to another position (drag reorder); the active tab stays active.</summary>
public bool Move(int from, int to)
{
if (from < 0 || from >= _tabs.Count || to < 0 || to >= _tabs.Count || from == to)
return false;
var active = ActiveTab;
var tab = _tabs[from];
_tabs.RemoveAt(from);
_tabs.Insert(to, tab);
_activeIndex = _tabs.IndexOf(active);
return true;
}

/// <summary>
/// Closes the tab. Closing the active tab activates its right neighbour
/// (or the new last tab), matching Explorer. Returns false for an invalid index.
/// </summary>
public bool CloseTab(int index)
{
if (index < 0 || index >= _tabs.Count) return false;
var closed = _tabs[index];
_tabs.RemoveAt(index);
if (_tabs.Count == 0)
_activeIndex = -1;
else if (index < _activeIndex)
_activeIndex--;
else if (_activeIndex >= _tabs.Count)
_activeIndex = _tabs.Count - 1;
OnTabClosed(closed);
return true;
}

/// <summary>Closes every tab except the given one and the close-exempt (pinned) ones.</summary>
public void CloseOthers(int index)
{
if (index < 0 || index >= _tabs.Count) return;
var keep = _tabs[index];
_tabs.Clear();
_tabs.Add(keep);
_activeIndex = 0;
for (int i = _tabs.Count - 1; i >= 0; i--)
{
if (ReferenceEquals(_tabs[i], keep) || IsCloseExempt(_tabs[i])) continue;
var closed = _tabs[i];
_tabs.RemoveAt(i);
OnTabClosed(closed);
}
_activeIndex = _tabs.IndexOf(keep);
}

/// <summary>Closes the tabs right of the given one, skipping close-exempt (pinned) ones.</summary>
public void CloseToRight(int index)
{
if (index < 0 || index >= _tabs.Count) return;
_tabs.RemoveRange(index + 1, _tabs.Count - index - 1);
if (_activeIndex >= _tabs.Count) _activeIndex = _tabs.Count - 1;
var active = ActiveTab;
for (int i = _tabs.Count - 1; i > index; i--)
{
if (IsCloseExempt(_tabs[i])) continue;
var closed = _tabs[i];
_tabs.RemoveAt(i);
OnTabClosed(closed);
}
_activeIndex = _tabs.IndexOf(active);
if (_activeIndex < 0) _activeIndex = Math.Min(index, _tabs.Count - 1);
}

/// <summary>Exempt tabs survive Close Others / Close to the Right (pinned tabs).</summary>
protected virtual bool IsCloseExempt(TTab tab) => false;

/// <summary>Called for every tab removed by a close operation (recently-closed history).</summary>
protected virtual void OnTabClosed(TTab tab) { }
}
}
98 changes: 96 additions & 2 deletions Editor/Model/TabSession.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,115 @@
using System;
using System.Collections.Generic;
using UnityEngine;

namespace Yozolab.Tabstep
{
/// <summary>Folder tabs of an Tabstep window — see <see cref="TabCollection{TTab}"/>.</summary>
/// <summary>
/// Folder tabs of an Tabstep window — see <see cref="TabCollection{TTab}"/>.
/// On top of the generic tab-bar logic this adds the folder-tab policies:
/// pinned tabs live in a leftmost region, and closed tabs are remembered so
/// they can be reopened (Ctrl+Shift+T).
/// </summary>
[Serializable]
class TabSession : TabCollection<TabState>
{
// Oldest entries are dropped past this; keeps serialized window state bounded.
internal const int MaxClosedTabs = 10;

[SerializeField] List<TabState> _recentlyClosed = new List<TabState>();

public bool HasClosedTabs => _recentlyClosed.Count > 0;

/// <summary>Most recently closed last; read-only peek for the start page.</summary>
public IReadOnlyList<TabState> RecentlyClosed => _recentlyClosed;

public TabState OpenTab(string folderPath, bool activate = true)
{
return Add(new TabState(folderPath), activate);
}

/// <summary>Opens a tab right after the active one (never inside the pinned region).</summary>
public TabState OpenTabAfterActive(string folderPath)
{
return AddTab(new TabState(folderPath), besideActive: true);
}

/// <summary>
/// Adds a prepared tab (possibly an empty start-page tab), either at the end of
/// the bar or right after the active tab — never inside the pinned region.
/// </summary>
public TabState AddTab(TabState tab, bool besideActive)
{
if (!besideActive || ActiveIndex < 0) return Add(tab);
return InsertAfter(Math.Max(ActiveIndex, PinnedCount - 1), tab);
}

/// <summary>Inserts a copy (including history) right after the source and activates it.</summary>
public TabState DuplicateTab(int index)
{
if (index < 0 || index >= Count) return null;
return InsertAfter(index, Tabs[index].Clone());
// A copy of a pinned tab is unpinned, so it must land outside the pinned region.
return InsertAfter(Math.Max(index, PinnedCount - 1), Tabs[index].Clone());
}

/// <summary>Number of leading pinned tabs — the pinned region is [0, PinnedCount).</summary>
public int PinnedCount
{
get
{
int n = 0;
while (n < Count && Tabs[n].Pinned) n++;
return n;
}
}

/// <summary>
/// Pins or unpins a tab, moving it to the matching edge of the pinned region
/// so pinned tabs always stay leftmost.
/// </summary>
public void SetPinned(int index, bool pinned)
{
if (index < 0 || index >= Count) return;
var tab = Tabs[index];
if (tab.Pinned == pinned) return;
int boundary = PinnedCount;
tab.Pinned = pinned;
Move(index, pinned ? boundary : boundary - 1);
}

/// <summary>
/// Drag-reorder constrained to the tab's own region (pinned stay among pinned).
/// Returns the index the tab ended up at, or -1 for an invalid <paramref name="from"/>.
/// </summary>
public int MoveTab(int from, int to)
{
if (from < 0 || from >= Count) return -1;
int pinned = PinnedCount;
to = Tabs[from].Pinned
? Math.Clamp(to, 0, pinned - 1)
: Math.Clamp(to, pinned, Count - 1);
Move(from, to);
return to;
}

/// <summary>Reopens the most recently closed tab (history included) as the active last tab.</summary>
public TabState ReopenClosedTab()
{
if (_recentlyClosed.Count == 0) return null;
var tab = _recentlyClosed[_recentlyClosed.Count - 1];
_recentlyClosed.RemoveAt(_recentlyClosed.Count - 1);
// Appending at the end — the pin (a slot property) does not come back with it.
tab.Pinned = false;
return Add(tab);
}

protected override bool IsCloseExempt(TabState tab) => tab.Pinned;

protected override void OnTabClosed(TabState tab)
{
_recentlyClosed.Add(tab);
if (_recentlyClosed.Count > MaxClosedTabs)
_recentlyClosed.RemoveAt(0);
}
}
}
25 changes: 25 additions & 0 deletions Editor/Model/TabState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class TabState

[SerializeField] List<string> _history = new List<string>();
[SerializeField] int _index = -1;
[SerializeField] bool _pinned;
[SerializeField] string _searchText = "";

public TabState() { }

Expand All @@ -28,6 +30,21 @@ public TabState(string folderPath)
public bool CanGoBack => _index > 0;
public bool CanGoForward => _index < _history.Count - 1;
public IReadOnlyList<string> History => _history;
public int HistoryIndex => _index;

/// <summary>Pinned tabs sit leftmost, render icon-only and resist accidental closing.</summary>
public bool Pinned
{
get => _pinned;
set => _pinned = value;
}

/// <summary>The tab's own search filter, restored when the tab becomes active again.</summary>
public string SearchText
{
get => _searchText;
set => _searchText = value ?? "";
}

/// <summary>
/// Enter a folder. Like a web browser, anything ahead of the current position
Expand Down Expand Up @@ -71,12 +88,20 @@ public string GoForward()
return CurrentPath;
}

/// <summary>Jumps straight to a history entry (the back/forward dropdown menu).</summary>
public void GoToHistoryIndex(int index)
{
if (index >= 0 && index < _history.Count) _index = index;
}

/// <summary>The copy is never pinned: pinning is a property of the tab's slot, not its content.</summary>
public TabState Clone()
{
return new TabState
{
_history = new List<string>(_history),
_index = _index,
_searchText = _searchText,
};
}
}
Expand Down
Loading
Loading