From 66bb50a9058570604427bb8ab04a49b19c1aed35 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 11:04:58 +0000 Subject: [PATCH 1/8] feat: tab power features and the Tabstep Shelf (0.1.3) Tabs: - Reopen closed tabs (Ctrl+Shift+T, context menu); last 10 remembered with history - Drag tab headers to reorder; pinned tabs stay in their leftmost region - Pinned tabs: icon-only, immune to Ctrl+W/middle-click and Close Others/To the Right - Back/forward right-click opens the full history as a dropdown - Breadcrumb separators list the parent's subfolders, like Explorer's chevrons - Ctrl+1..8 jump to a tab, Ctrl+9 to the last; Ctrl+Shift+C copies the absolute path - Quick Access bookmarks on right-clicking +, stored per project in UserSettings/ - Per-tab search text, restored when the tab becomes active again - Same-named tabs disambiguated with their parent folder; tab-list dropdown at the right - Optional: new tabs open beside the active tab Tabstep Shelf: - Borderless, focus-free popup tray for objects in transit between tabs, Inspector object fields, the Hierarchy and the Scene view - Fed by direct drops, a drop zone that appears in the tab bar during drags, or Send Selection to Shelf; items stored as GUID/GlobalObjectId - One-shot hand-off semantics (toggleable): dragging out consumes the item, dragging back restores it; empty unpinned shelf closes itself - Survives domain reloads, closes with its window unless pinned --- Editor/Model/ShelfItem.cs | 81 ++++ Editor/Model/ShelfItem.cs.meta | 11 + Editor/Model/TabCollection.cs | 46 ++- Editor/Model/TabSession.cs | 86 +++- Editor/Model/TabState.cs | 25 ++ Editor/TabstepSettings.cs | 31 +- Editor/Utils/TabstepBookmarks.cs | 87 ++++ Editor/Utils/TabstepBookmarks.cs.meta | 11 + Editor/Window/TabstepProjectWindow.cs | 493 +++++++++++++++++++++-- Editor/Window/TabstepShelfWindow.cs | 343 ++++++++++++++++ Editor/Window/TabstepShelfWindow.cs.meta | 11 + README.md | 83 +++- Tests/TabSessionTests.cs | 146 +++++++ Tests/TabStateTests.cs | 39 ++ package.json | 2 +- 15 files changed, 1434 insertions(+), 61 deletions(-) create mode 100644 Editor/Model/ShelfItem.cs create mode 100644 Editor/Model/ShelfItem.cs.meta create mode 100644 Editor/Utils/TabstepBookmarks.cs create mode 100644 Editor/Utils/TabstepBookmarks.cs.meta create mode 100644 Editor/Window/TabstepShelfWindow.cs create mode 100644 Editor/Window/TabstepShelfWindow.cs.meta diff --git a/Editor/Model/ShelfItem.cs b/Editor/Model/ShelfItem.cs new file mode 100644 index 0000000..53d51fc --- /dev/null +++ b/Editor/Model/ShelfItem.cs @@ -0,0 +1,81 @@ +using System; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace Yozolab.Tabstep +{ + /// + /// 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. + /// + [Serializable] + class ShelfItem + { + [SerializeField] string _globalId = ""; + [SerializeField] string _guid = ""; + [SerializeField] string _displayName = ""; + + [NonSerialized] Object _cached; + + ShelfItem() { } + + /// Null when the object cannot be identified (destroyed, not yet persisted...). + 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; + } + + /// Stable identity for dedup and for tracking an item through a drag. + public string Key => string.IsNullOrEmpty(_globalId) ? _guid : _globalId; + + public bool IsAsset => !string.IsNullOrEmpty(_guid); + + /// Project path for assets (feeds DragAndDrop.paths), null for scene objects. + 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; + } + } + + /// The live object, or null when it no longer exists (shown greyed out). + 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; + } + } +} diff --git a/Editor/Model/ShelfItem.cs.meta b/Editor/Model/ShelfItem.cs.meta new file mode 100644 index 0000000..a806ae8 --- /dev/null +++ b/Editor/Model/ShelfItem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4bdadc6f330a4ae28788ea5b33d311fe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Model/TabCollection.cs b/Editor/Model/TabCollection.cs index 257406f..56edc57 100644 --- a/Editor/Model/TabCollection.cs +++ b/Editor/Model/TabCollection.cs @@ -60,6 +60,19 @@ public void CycleActive(int delta) _activeIndex = ((_activeIndex + delta) % _tabs.Count + _tabs.Count) % _tabs.Count; } + /// Moves a tab to another position (drag reorder); the active tab stays active. + 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; + } + /// /// Closes the tab. Closing the active tab activates its right neighbour /// (or the new last tab), matching Explorer. Returns false for an invalid index. @@ -67,6 +80,7 @@ public void CycleActive(int delta) 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; @@ -74,23 +88,45 @@ public bool CloseTab(int index) _activeIndex--; else if (_activeIndex >= _tabs.Count) _activeIndex = _tabs.Count - 1; + OnTabClosed(closed); return true; } + /// Closes every tab except the given one and the close-exempt (pinned) ones. 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); } + /// Closes the tabs right of the given one, skipping close-exempt (pinned) ones. 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); } + + /// Exempt tabs survive Close Others / Close to the Right (pinned tabs). + protected virtual bool IsCloseExempt(TTab tab) => false; + + /// Called for every tab removed by a close operation (recently-closed history). + protected virtual void OnTabClosed(TTab tab) { } } } diff --git a/Editor/Model/TabSession.cs b/Editor/Model/TabSession.cs index caf162f..c55f67c 100644 --- a/Editor/Model/TabSession.cs +++ b/Editor/Model/TabSession.cs @@ -1,21 +1,103 @@ using System; +using System.Collections.Generic; +using UnityEngine; namespace Yozolab.Tabstep { - /// Folder tabs of an Tabstep window — see . + /// + /// Folder tabs of an Tabstep window — see . + /// 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). + /// [Serializable] class TabSession : TabCollection { + // Oldest entries are dropped past this; keeps serialized window state bounded. + internal const int MaxClosedTabs = 10; + + [SerializeField] List _recentlyClosed = new List(); + + public bool HasClosedTabs => _recentlyClosed.Count > 0; + public TabState OpenTab(string folderPath, bool activate = true) { return Add(new TabState(folderPath), activate); } + /// Opens a tab right after the active one (never inside the pinned region). + public TabState OpenTabAfterActive(string folderPath) + { + if (ActiveIndex < 0) return OpenTab(folderPath); + return InsertAfter(Math.Max(ActiveIndex, PinnedCount - 1), new TabState(folderPath)); + } + /// Inserts a copy (including history) right after the source and activates it. 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()); + } + + /// Number of leading pinned tabs — the pinned region is [0, PinnedCount). + public int PinnedCount + { + get + { + int n = 0; + while (n < Count && Tabs[n].Pinned) n++; + return n; + } + } + + /// + /// Pins or unpins a tab, moving it to the matching edge of the pinned region + /// so pinned tabs always stay leftmost. + /// + 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); + } + + /// + /// 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 . + /// + 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; + } + + /// Reopens the most recently closed tab (history included) as the active last tab. + 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); } } } diff --git a/Editor/Model/TabState.cs b/Editor/Model/TabState.cs index 4d10427..2a6c94b 100644 --- a/Editor/Model/TabState.cs +++ b/Editor/Model/TabState.cs @@ -16,6 +16,8 @@ class TabState [SerializeField] List _history = new List(); [SerializeField] int _index = -1; + [SerializeField] bool _pinned; + [SerializeField] string _searchText = ""; public TabState() { } @@ -28,6 +30,21 @@ public TabState(string folderPath) public bool CanGoBack => _index > 0; public bool CanGoForward => _index < _history.Count - 1; public IReadOnlyList History => _history; + public int HistoryIndex => _index; + + /// Pinned tabs sit leftmost, render icon-only and resist accidental closing. + public bool Pinned + { + get => _pinned; + set => _pinned = value; + } + + /// The tab's own search filter, restored when the tab becomes active again. + public string SearchText + { + get => _searchText; + set => _searchText = value ?? ""; + } /// /// Enter a folder. Like a web browser, anything ahead of the current position @@ -71,12 +88,20 @@ public string GoForward() return CurrentPath; } + /// Jumps straight to a history entry (the back/forward dropdown menu). + public void GoToHistoryIndex(int index) + { + if (index >= 0 && index < _history.Count) _index = index; + } + + /// The copy is never pinned: pinning is a property of the tab's slot, not its content. public TabState Clone() { return new TabState { _history = new List(_history), _index = _index, + _searchText = _searchText, }; } } diff --git a/Editor/TabstepSettings.cs b/Editor/TabstepSettings.cs index e4d16c1..76e55ca 100644 --- a/Editor/TabstepSettings.cs +++ b/Editor/TabstepSettings.cs @@ -22,6 +22,20 @@ public static bool MiddleClickClosesTab set => EditorPrefs.SetBool(Prefix + "MiddleClickClosesTab", value); } + /// New tabs open right of the active tab instead of at the end of the bar. + public static bool NewTabBesideActive + { + get => EditorPrefs.GetBool(Prefix + "NewTabBesideActive", false); + set => EditorPrefs.SetBool(Prefix + "NewTabBesideActive", value); + } + + /// Dragging an item out of the shelf consumes it (it is a hand-off, not a copy source). + public static bool ShelfOneShot + { + get => EditorPrefs.GetBool(Prefix + "ShelfOneShot", true); + set => EditorPrefs.SetBool(Prefix + "ShelfOneShot", value); + } + /// /// Show the navigation bar (back/forward/up + address bar) under the tabs. It /// replaces the browser's own "Assets > ..." path header, and — with Harmony @@ -60,7 +74,7 @@ public static void ResetAll() string[] keys = { "NewTabFolder", "MiddleClickClosesTab", "ShowNavigationBar", "MaxTabTitleLength", - "PingOpensNewTab", "MouseSideButtonsNavigate", + "PingOpensNewTab", "MouseSideButtonsNavigate", "NewTabBesideActive", "ShelfOneShot", }; foreach (var key in keys) EditorPrefs.DeleteKey(Prefix + key); @@ -83,6 +97,7 @@ static SettingsProvider Create() { "project", "browser", "tab", "explorer", "breadcrumb", "history", "inspector", "ping", "double click", "mouse", "side button", "path header", + "shelf", "pin", "quick access", "bookmark", "reorder", }), }; } @@ -99,6 +114,10 @@ static void DrawGui() TabstepSettings.MiddleClickClosesTab = EditorGUILayout.Toggle( new GUIContent("Middle-Click Closes Tab"), TabstepSettings.MiddleClickClosesTab); + TabstepSettings.NewTabBesideActive = EditorGUILayout.Toggle( + new GUIContent("Open New Tab Beside Active", + "New tabs open right of the active tab instead of at the end of the bar."), + TabstepSettings.NewTabBesideActive); TabstepSettings.MaxTabTitleLength = EditorGUILayout.IntSlider( new GUIContent("Max Tab Title Length"), TabstepSettings.MaxTabTitleLength, 4, 60); @@ -131,6 +150,16 @@ static void DrawGui() TabstepSettings.PingOpensNewTab); EditorGUI.indentLevel--; + EditorGUILayout.Space(8); + EditorGUILayout.LabelField("Shelf", EditorStyles.boldLabel); + EditorGUI.indentLevel++; + TabstepSettings.ShelfOneShot = EditorGUILayout.Toggle( + new GUIContent("One-Shot Items", + "Dragging an item out of the shelf consumes it — the shelf is a hand-off, " + + "not a copy source. Off keeps items until removed manually."), + TabstepSettings.ShelfOneShot); + EditorGUI.indentLevel--; + EditorGUILayout.Space(10); if (GUILayout.Button("Reset To Defaults", GUILayout.Width(160))) TabstepSettings.ResetAll(); diff --git a/Editor/Utils/TabstepBookmarks.cs b/Editor/Utils/TabstepBookmarks.cs new file mode 100644 index 0000000..7970102 --- /dev/null +++ b/Editor/Utils/TabstepBookmarks.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEngine; + +namespace Yozolab.Tabstep +{ + /// + /// The Quick Access folder list (right-click on the + tab button). Stored per + /// user and per project in UserSettings/ — not in EditorPrefs — because the + /// paths only mean something inside this project. + /// + static class TabstepBookmarks + { + [Serializable] + class Payload + { + public List folders = new List(); + } + + static Payload _payload; // cache; dropped on domain reload, reloaded lazily + + static string FilePath + { + get + { + var projectRoot = Path.GetDirectoryName(Path.GetFullPath(Application.dataPath)); + return Path.Combine(projectRoot, "UserSettings", "TabstepQuickAccess.json"); + } + } + + public static IReadOnlyList Folders => Load().folders; + + public static bool Contains(string folderPath) + { + folderPath = ProjectPaths.Normalize(folderPath); + return folderPath != null && Load().folders.Contains(folderPath); + } + + public static void Add(string folderPath) + { + folderPath = ProjectPaths.Normalize(folderPath); + if (folderPath == null || Load().folders.Contains(folderPath)) return; + Load().folders.Add(folderPath); + Save(); + } + + public static void Remove(string folderPath) + { + folderPath = ProjectPaths.Normalize(folderPath); + if (folderPath != null && Load().folders.Remove(folderPath)) + Save(); + } + + static Payload Load() + { + if (_payload != null) return _payload; + _payload = new Payload(); + try + { + if (File.Exists(FilePath)) + { + var loaded = JsonUtility.FromJson(File.ReadAllText(FilePath)); + if (loaded?.folders != null) _payload = loaded; + } + } + catch (Exception e) + { + Debug.LogWarning($"[Tabstep] Could not read the Quick Access list: {e.Message}"); + } + return _payload; + } + + static void Save() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)); + File.WriteAllText(FilePath, JsonUtility.ToJson(Load(), true)); + } + catch (Exception e) + { + Debug.LogWarning($"[Tabstep] Could not save the Quick Access list: {e.Message}"); + } + } + } +} diff --git a/Editor/Utils/TabstepBookmarks.cs.meta b/Editor/Utils/TabstepBookmarks.cs.meta new file mode 100644 index 0000000..a752e2d --- /dev/null +++ b/Editor/Utils/TabstepBookmarks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1753315231aa42d080eef04aa63ff00d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Window/TabstepProjectWindow.cs b/Editor/Window/TabstepProjectWindow.cs index 7a3d94d..c892c17 100644 --- a/Editor/Window/TabstepProjectWindow.cs +++ b/Editor/Window/TabstepProjectWindow.cs @@ -17,9 +17,11 @@ namespace Yozolab.Tabstep /// disappear entirely: their create button and search field move into the /// navigation bar, and the freed rows go to the folder view. /// - /// Shortcuts: Ctrl+T new tab, Ctrl+W close tab, Ctrl(+Shift)+Tab cycle tabs, + /// Shortcuts: Ctrl+T new tab, Ctrl+W close tab, Ctrl+Shift+T reopen closed tab, + /// Ctrl(+Shift)+Tab cycle tabs, Ctrl+1..9 jump to a tab (9 = last), /// Alt+Left/Right or mouse side buttons back/forward, Alt+Up parent folder, - /// Ctrl+L / Alt+D edit the path, Ctrl+F focus the search field. + /// Ctrl+L / Alt+D edit the path, Ctrl+F focus the search field, + /// Ctrl+Shift+C copy the absolute path, Ctrl+Shift+D toggle the shelf. /// public class TabstepProjectWindow : EditorWindow { @@ -80,6 +82,25 @@ public class TabstepProjectWindow : EditorWindow int _dragHoverTab = -1; double _dragHoverStart; + // Tab titles for this pass: display names, disambiguated with the parent folder + // when several tabs share a name. Rebuilt at the top of every DrawTabBar. + readonly List _tabTitles = new List(); + // Tab header rects from the last Repaint pass — drag reorder hit-testing. + readonly List _tabRects = new List(); + + // Drag reorder: one window-level control captures the mouse; the dragged tab + // swaps with a neighbour when the cursor crosses that neighbour's center. + static readonly int TabReorderHash = "Tabstep.TabReorder".GetHashCode(); + int _reorderIndex = -1; + float _reorderStartX; + bool _reordering; + + // True while an asset drag is in flight over this window; shows the shelf drop zone. + // The zone's visibility is latched at Layout (_dragZoneVisible) so the control + // layout never changes between a Layout pass and the event passes that follow it. + bool _dragActive; + bool _dragZoneVisible; + [MenuItem("YozoLab/Tabstep")] public static TabstepProjectWindow Open() { @@ -109,7 +130,9 @@ static string GetSelectedFolderPath() /// Opens (or the default folder) as a new active tab. public void OpenInNewTab(string folderPath) { - _session.OpenTab(ValidFolderOrDefault(folderPath)); + var path = ValidFolderOrDefault(folderPath); + if (TabstepSettings.NewTabBesideActive) _session.OpenTabAfterActive(path); + else _session.OpenTab(path); _applyTabToBrowser = true; Repaint(); } @@ -139,6 +162,14 @@ void OnDisable() _host = null; } + void OnDestroy() + { + // The shelf belongs to this window unless the user pinned it. + // (OnDestroy, not OnDisable: domain reloads must not close the shelf.) + if (TabstepShelfWindow.IsOpen && !TabstepShelfWindow.Instance.KeepOpen) + TabstepShelfWindow.Instance.Close(); + } + void OnGUI() { if (!ProjectBrowserHost.IsAvailable) @@ -152,6 +183,7 @@ void OnGUI() if (Event.current.type == EventType.Layout) SyncWithBrowser(); + TrackDragState(); HandleShortcuts(); HandleMouseNavigation(); @@ -188,9 +220,22 @@ void SyncWithBrowser() _host.ShowFolder(tab.CurrentPath); } _observedBrowserPath = tab.CurrentPath; + // The browser is shared between tabs, so each tab carries its own + // search filter and gets it back when it becomes active again. + var saved = tab.SearchText ?? ""; + if ((_host.GetSearchText() ?? "") != saved) + _host.SetSearch(saved); + _searchText = saved; + _lastAppliedSearch = _host.GetSearchText() ?? saved; return; } + // Mirror the browser's live filter into the active tab (typed into either + // search field, or cleared by the browser when a folder is clicked). + var browserSearch = _host.GetSearchText(); + if (browserSearch != null) + tab.SearchText = browserSearch; + // The user navigated inside the embedded browser (double-clicked a folder, // used the breadcrumb of the browser itself...) — record it in the tab. var browserPath = _host.GetActiveFolderPath(); @@ -264,14 +309,46 @@ void HandleShortcuts() if (e.type != EventType.KeyDown) return; bool ctrl = e.control || e.command; - if (ctrl && e.keyCode == KeyCode.T) + if (ctrl && e.shift && e.keyCode == KeyCode.T) + { + ReopenClosedTab(); + e.Use(); + } + else if (ctrl && e.keyCode == KeyCode.T) { OpenInNewTab(null); e.Use(); } else if (ctrl && e.keyCode == KeyCode.W) { - CloseTab(_session.ActiveIndex); + // Pinned tabs don't close from the keyboard — that's the point of the pin. + if (_session.ActiveTab != null && _session.ActiveTab.Pinned) + ShowNotification(new GUIContent("Tab is pinned")); + else + CloseTab(_session.ActiveIndex); + e.Use(); + } + else if (ctrl && e.shift && e.keyCode == KeyCode.C) + { + if (_session.ActiveTab != null) + { + EditorGUIUtility.systemCopyBuffer = ToAbsolutePath(_session.ActiveTab.CurrentPath); + ShowNotification(new GUIContent("Absolute path copied")); + } + e.Use(); + } + else if (ctrl && e.shift && e.keyCode == KeyCode.D) + { + TabstepShelfWindow.Toggle(this); + e.Use(); + } + else if (ctrl && !e.shift && e.keyCode >= KeyCode.Alpha1 && e.keyCode <= KeyCode.Alpha9) + { + // Ctrl+1..8 jump to that tab, Ctrl+9 to the last one (browser convention). + int target = e.keyCode == KeyCode.Alpha9 + ? _session.Count - 1 + : e.keyCode - KeyCode.Alpha1; + if (target >= 0 && target < _session.Count) ActivateTab(target); e.Use(); } else if (ctrl && e.keyCode == KeyCode.Tab) @@ -333,6 +410,57 @@ void HandleMouseNavigation() } } + /// + /// Tracks whether an asset drag is hovering the window — the tab bar shows the + /// shelf drop zone only while one is. Runs before the bar and the embedded + /// browser, so it sees the events even when they get consumed later. + /// + void TrackDragState() + { + var e = Event.current; + if (e.type == EventType.Layout) + { + _dragZoneVisible = _dragActive; + return; + } + if (e.type == EventType.DragUpdated) + { + bool active = DragAndDrop.objectReferences.Length > 0; + if (active != _dragActive) + { + _dragActive = active; + Repaint(); + } + } + // Not on DragPerform: the drop zone itself still has to see that event later + // in this same pass. The DragExited that follows (or the first plain mouse + // event after the drag) hides the zone again. + else if (_dragActive && + (e.type == EventType.DragExited || e.type == EventType.MouseMove || + e.type == EventType.MouseDown || e.type == EventType.MouseDrag)) + { + _dragActive = false; + Repaint(); + } + } + + void ReopenClosedTab() + { + if (_session.ReopenClosedTab() == null) + { + ShowNotification(new GUIContent("No recently closed tabs")); + return; + } + _applyTabToBrowser = true; + Repaint(); + } + + /// Path as a single GenericMenu item label — '/' would create submenus. + static string MenuPath(string path) + { + return (path ?? "").Replace("/", " › "); + } + // ---- path bar copy / paste ---------------------------------------------- /// Project root on disk — the folder that contains Assets. @@ -426,10 +554,12 @@ void CommitPathEdit() void DrawTabBar() { + BuildTabTitles(); + int reorderControl = GUIUtility.GetControlID(TabReorderHash, FocusType.Passive); EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); for (int i = 0; i < _session.Count; i++) { - if (DrawTab(i)) + if (DrawTab(i, reorderControl)) { // Structure changed mid-loop (a tab closed) — bail out of this pass. EditorGUILayout.EndHorizontal(); @@ -437,27 +567,70 @@ void DrawTabBar() return; } } - if (GUILayout.Button(new GUIContent("+", "New tab (Ctrl+T)"), EditorStyles.toolbarButton, - GUILayout.Width(26))) - OpenInNewTab(null); + DrawNewTabButton(); GUILayout.FlexibleSpace(); + DrawShelfDropZone(); + DrawTabListButton(); EditorGUILayout.EndHorizontal(); - HandleTabBarDragAndDrop(GUILayoutUtility.GetLastRect()); + var barRect = GUILayoutUtility.GetLastRect(); + HandleTabReorder(reorderControl); + HandleTabBarDragAndDrop(barRect); + } + + /// + /// Tab titles for this pass. Tabs sharing a display name (Scripts, Textures...) + /// get their parent folder appended so they stay distinguishable. + /// + void BuildTabTitles() + { + _tabTitles.Clear(); + var counts = new Dictionary(); + foreach (var tab in _session.Tabs) + { + var name = ProjectPaths.GetDisplayName(tab.CurrentPath) ?? "(empty)"; + counts[name] = counts.TryGetValue(name, out var c) ? c + 1 : 1; + } + foreach (var tab in _session.Tabs) + { + var name = ProjectPaths.GetDisplayName(tab.CurrentPath) ?? "(empty)"; + if (counts[name] > 1) + { + var parent = ProjectPaths.GetDisplayName(ProjectPaths.GetParent(tab.CurrentPath)); + if (parent != null) name = name + " — " + parent; + } + _tabTitles.Add(ProjectPaths.Ellipsize(name, TabstepSettings.MaxTabTitleLength)); + } + while (_tabRects.Count < _session.Count) _tabRects.Add(Rect.zero); + while (_tabRects.Count > _session.Count) _tabRects.RemoveAt(_tabRects.Count - 1); } /// Draws one tab. Returns true when the tab was closed (layout is now stale). - bool DrawTab(int index) + bool DrawTab(int index, int reorderControl) { var tab = _session.Tabs[index]; bool active = index == _session.ActiveIndex; - string title = ProjectPaths.Ellipsize( - ProjectPaths.GetDisplayName(tab.CurrentPath) ?? "(empty)", - TabstepSettings.MaxTabTitleLength); - // Trailing spaces reserve room for the close glyph drawn over the active tab. - var content = new GUIContent(active ? title + " " : title, tab.CurrentPath); - var style = EditorStyles.toolbarButton; - var rect = GUILayoutUtility.GetRect(content, style, GUILayout.MaxWidth(200)); + + GUIContent content; + Rect rect; + if (tab.Pinned) + { + // Pinned tabs shrink to their folder icon, like a browser's pinned tabs. + var icon = AssetDatabase.GetCachedIcon(tab.CurrentPath); + content = icon != null + ? new GUIContent(icon, tab.CurrentPath) + : new GUIContent(ProjectPaths.Ellipsize(_tabTitles[index], 4), tab.CurrentPath); + rect = GUILayoutUtility.GetRect(content, style, GUILayout.Width(28)); + } + else + { + string title = _tabTitles[index]; + // Trailing spaces reserve room for the close glyph drawn over the active tab. + content = new GUIContent(active ? title + " " : title, tab.CurrentPath); + rect = GUILayoutUtility.GetRect(content, style, GUILayout.MaxWidth(200)); + } + if (Event.current.type == EventType.Repaint && index < _tabRects.Count) + _tabRects[index] = rect; var closeRect = new Rect(rect.xMax - 18, rect.y + (rect.height - 16) / 2, 16, 16); HandleTabDrag(rect, index, tab); @@ -465,13 +638,26 @@ bool DrawTab(int index) var e = Event.current; if (e.type == EventType.MouseDown && rect.Contains(e.mousePosition)) { - if (active && e.button == 0 && closeRect.Contains(e.mousePosition)) + if (e.button == 0) { + if (active && !tab.Pinned && closeRect.Contains(e.mousePosition)) + { + e.Use(); + CloseTab(index); + return true; + } + // Activate on press (not release) so a reorder drag starts from the + // same press; the window-level control keeps the capture while the + // tab indices shift under the cursor. + if (!active) ActivateTab(index); + GUIUtility.hotControl = reorderControl; + _reorderIndex = index; + _reorderStartX = e.mousePosition.x; + _reordering = false; e.Use(); - CloseTab(index); - return true; + return false; } - if (e.button == 2 && TabstepSettings.MiddleClickClosesTab) + if (e.button == 2 && TabstepSettings.MiddleClickClosesTab && !tab.Pinned) { e.Use(); CloseTab(index); @@ -485,13 +671,154 @@ bool DrawTab(int index) } } - if (GUI.Toggle(rect, active, content, style) && !active) - ActivateTab(index); - if (active) + GUI.Toggle(rect, active, content, style); // visuals only; clicks are handled above + if (active && !tab.Pinned) GUI.Label(closeRect, new GUIContent("×", "Close tab (Ctrl+W)"), EditorStyles.miniLabel); return false; } + void DrawNewTabButton() + { + var content = new GUIContent("+", "New tab (Ctrl+T)\nRight-click: Quick Access"); + var rect = GUILayoutUtility.GetRect(content, EditorStyles.toolbarButton, GUILayout.Width(26)); + var e = Event.current; + if (e.type == EventType.MouseDown && e.button == 1 && rect.Contains(e.mousePosition)) + { + e.Use(); + ShowQuickAccessMenu(rect); + return; + } + if (GUI.Button(rect, content, EditorStyles.toolbarButton)) + OpenInNewTab(null); + } + + /// Quick Access — bookmarked folders that open as new tabs (right-click the +). + void ShowQuickAccessMenu(Rect dropRect) + { + var menu = new GenericMenu(); + if (TabstepBookmarks.Folders.Count == 0) + menu.AddDisabledItem(new GUIContent("No Quick Access folders")); + foreach (var folder in TabstepBookmarks.Folders) + { + var path = folder; + if (AssetDatabase.IsValidFolder(path)) + menu.AddItem(new GUIContent(MenuPath(path)), false, () => OpenInNewTab(path)); + else + menu.AddDisabledItem(new GUIContent(MenuPath(path))); + } + menu.AddSeparator(""); + var current = _session.ActiveTab?.CurrentPath; + if (current != null && !TabstepBookmarks.Contains(current)) + menu.AddItem(new GUIContent("Add Current Folder"), false, () => TabstepBookmarks.Add(current)); + else + menu.AddDisabledItem(new GUIContent("Add Current Folder")); + foreach (var folder in TabstepBookmarks.Folders) + { + var path = folder; + menu.AddItem(new GUIContent("Remove/" + MenuPath(path)), false, + () => TabstepBookmarks.Remove(path)); + } + menu.DropDown(dropRect); + } + + /// Every tab as a dropdown — the escape hatch when the bar overflows. + void DrawTabListButton() + { + var content = new GUIContent("▾", "All tabs"); + var rect = GUILayoutUtility.GetRect(content, EditorStyles.toolbarButton, GUILayout.Width(20)); + if (!EditorGUI.DropdownButton(rect, content, FocusType.Passive, EditorStyles.toolbarButton)) + return; + var menu = new GenericMenu(); + for (int i = 0; i < _session.Count; i++) + { + int index = i; + menu.AddItem(new GUIContent(MenuPath(_session.Tabs[i].CurrentPath)), + i == _session.ActiveIndex, () => ActivateTab(index)); + } + menu.DropDown(rect); + } + + /// + /// Appears at the right of the tab bar only while assets are being dragged: + /// dropping parks them on the shelf for a later hand-off instead of moving them. + /// + void DrawShelfDropZone() + { + if (!_dragZoneVisible) return; + var content = new GUIContent("▼ Shelf", "Drop here to park on the shelf"); + var rect = GUILayoutUtility.GetRect(content, EditorStyles.toolbarButton, GUILayout.Width(64)); + var e = Event.current; + bool hover = rect.Contains(e.mousePosition); + if (e.type == EventType.Repaint) + { + EditorStyles.toolbarButton.Draw(rect, content, hover, false, false, false); + EditorGUI.DrawRect(rect, new Color(0.5f, 0.7f, 1f, hover ? 0.35f : 0.15f)); + } + if ((e.type == EventType.DragUpdated || e.type == EventType.DragPerform) && hover) + { + if (DragAndDrop.objectReferences.Length == 0) return; + DragAndDrop.visualMode = DragAndDropVisualMode.Copy; + if (e.type == EventType.DragPerform) + { + DragAndDrop.AcceptDrag(); + TabstepShelfWindow.ShowNear(this).AddObjects(DragAndDrop.objectReferences); + } + e.Use(); + } + } + + // ---- tab drag reorder ---------------------------------------------------- + + /// Drag a tab header sideways to reorder; pinned tabs stay among pinned. + void HandleTabReorder(int controlId) + { + if (GUIUtility.hotControl != controlId) return; + var e = Event.current; + switch (e.rawType) + { + case EventType.MouseDrag: + if (_reorderIndex < 0 || _reorderIndex >= _session.Count) break; + if (!_reordering && Mathf.Abs(e.mousePosition.x - _reorderStartX) < 5f) + { + e.Use(); + break; + } + _reordering = true; + int target = TabIndexAt(e.mousePosition.x); + if (target >= 0 && target != _reorderIndex && CrossedCenter(target, e.mousePosition.x)) + { + int moved = _session.MoveTab(_reorderIndex, target); + if (moved >= 0) _reorderIndex = moved; + Repaint(); + } + e.Use(); + break; + case EventType.MouseUp: + GUIUtility.hotControl = 0; + _reorderIndex = -1; + _reordering = false; + e.Use(); + break; + } + } + + int TabIndexAt(float x) + { + int count = Math.Min(_session.Count, _tabRects.Count); + for (int i = 0; i < count; i++) + if (x >= _tabRects[i].xMin && x < _tabRects[i].xMax) + return i; + return -1; + } + + /// Swap only once the cursor passes the neighbour's center — avoids flicker. + bool CrossedCenter(int target, float x) + { + if (target < 0 || target >= _tabRects.Count) return false; + float center = _tabRects[target].center.x; + return target > _reorderIndex ? x > center : x < center; + } + void ShowTabContextMenu(int index) { var menu = new GenericMenu(); @@ -514,6 +841,28 @@ void ShowTabContextMenu(int index) }); else menu.AddDisabledItem(new GUIContent("Close Tabs to the Right")); + if (_session.HasClosedTabs) + menu.AddItem(new GUIContent("Reopen Closed Tab"), false, ReopenClosedTab); + else + menu.AddDisabledItem(new GUIContent("Reopen Closed Tab")); + menu.AddSeparator(""); + var tab = _session.Tabs[index]; + menu.AddItem(new GUIContent(tab.Pinned ? "Unpin Tab" : "Pin Tab"), false, () => + { + _session.SetPinned(index, !tab.Pinned); + Repaint(); + }); + if (TabstepBookmarks.Contains(tab.CurrentPath)) + menu.AddItem(new GUIContent("Remove from Quick Access"), false, + () => TabstepBookmarks.Remove(tab.CurrentPath)); + else + menu.AddItem(new GUIContent("Add to Quick Access"), false, + () => TabstepBookmarks.Add(tab.CurrentPath)); + if (Selection.objects.Length > 0) + menu.AddItem(new GUIContent("Send Selection to Shelf"), false, + () => TabstepShelfWindow.ShowNear(this).AddObjects(Selection.objects)); + else + menu.AddDisabledItem(new GUIContent("Send Selection to Shelf")); menu.AddSeparator(""); menu.AddItem(new GUIContent("Duplicate Tab"), false, () => { @@ -650,13 +999,21 @@ void DrawNavigationBar() bool integrated = ProjectBrowserPatcher.Active; EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + // Back/forward get explicit rects: right-clicking either lists the history, + // and that must work even while the button itself is disabled. + var backContent = new GUIContent("◀", "Back (Alt+Left)\nRight-click: history"); + var backRect = GUILayoutUtility.GetRect(backContent, EditorStyles.toolbarButton, + GUILayout.Width(26)); + HandleHistoryMenuClick(backRect, tab); using (new EditorGUI.DisabledScope(tab == null || !tab.CanGoBack)) - if (GUILayout.Button(new GUIContent("◀", "Back (Alt+Left)"), EditorStyles.toolbarButton, - GUILayout.Width(26))) + if (GUI.Button(backRect, backContent, EditorStyles.toolbarButton)) GoBack(); + var forwardContent = new GUIContent("▶", "Forward (Alt+Right)\nRight-click: history"); + var forwardRect = GUILayoutUtility.GetRect(forwardContent, EditorStyles.toolbarButton, + GUILayout.Width(26)); + HandleHistoryMenuClick(forwardRect, tab); using (new EditorGUI.DisabledScope(tab == null || !tab.CanGoForward)) - if (GUILayout.Button(new GUIContent("▶", "Forward (Alt+Right)"), EditorStyles.toolbarButton, - GUILayout.Width(26))) + if (GUI.Button(forwardRect, forwardContent, EditorStyles.toolbarButton)) GoForward(); var parent = ProjectPaths.GetParent(tab?.CurrentPath); using (new EditorGUI.DisabledScope(parent == null || !AssetDatabase.IsValidFolder(parent))) @@ -678,6 +1035,8 @@ void DrawNavigationBar() GUILayout.Space(2); } + DrawShelfToggle(); + EditorGUILayout.EndHorizontal(); addressRect.y += 1; @@ -685,6 +1044,44 @@ void DrawNavigationBar() DrawAddressBar(addressRect, tab); } + void HandleHistoryMenuClick(Rect rect, TabState tab) + { + var e = Event.current; + if (e.type != EventType.MouseDown || e.button != 1 || !rect.Contains(e.mousePosition)) + return; + e.Use(); + ShowHistoryMenu(rect, tab); + } + + /// Right-clicking back/forward lists the whole history, newest first. + void ShowHistoryMenu(Rect dropRect, TabState tab) + { + if (tab == null || tab.History.Count == 0) return; + var menu = new GenericMenu(); + for (int i = tab.History.Count - 1; i >= 0; i--) + { + int index = i; + menu.AddItem(new GUIContent(MenuPath(tab.History[i])), i == tab.HistoryIndex, () => + { + tab.GoToHistoryIndex(index); + _applyTabToBrowser = true; + Repaint(); + }); + } + menu.DropDown(dropRect); + } + + void DrawShelfToggle() + { + bool open = TabstepShelfWindow.IsOpen; + bool now = GUILayout.Toggle(open, + new GUIContent("Shelf", + "The shelf — a temporary tray for assets in transit between tabs, " + + "Inspector fields and the scene (Ctrl+Shift+D)"), + EditorStyles.toolbarButton, GUILayout.ExpandWidth(false)); + if (now != open) TabstepShelfWindow.Toggle(this); + } + /// The stock toolbar's "+" dropdown: the Assets/Create menu. void DrawCreateButton() { @@ -730,6 +1127,9 @@ void DrawSearchField() // SetSearch round-trips the text through the filter; remember the normalized // form so next frame's mirror check doesn't stomp what the user is typing. _lastAppliedSearch = _host.GetSearchText() ?? edited; + // The filter belongs to the tab: it comes back when the tab is next active. + if (_session.ActiveTab != null) + _session.ActiveTab.SearchText = edited; } /// @@ -781,7 +1181,7 @@ void DrawAddressBar(Rect rect, TabState tab) for (int i = firstVisible; i < crumbs.Count; i++) { if (i > firstVisible || firstVisible > 0) - x = DrawCrumbSeparator(x, inner); + x = DrawCrumbSeparator(x, inner, crumbs[i - 1].path, crumbs[i].path); x = DrawCrumb(crumbs[i], i == crumbs.Count - 1, x, inner); } @@ -839,12 +1239,41 @@ float DrawHiddenCrumbsButton(List<(string name, string path)> crumbs, int hidden return rect.xMax; } - float DrawCrumbSeparator(float x, Rect inner) + /// + /// The › between crumbs is a control of its own, like Explorer's chevrons: + /// clicking it lists the parent crumb's subfolders for a sideways jump. + /// + float DrawCrumbSeparator(float x, Rect inner, string parentPath, string currentChildPath) { - GUI.Label(new Rect(x, inner.y, CrumbSeparatorWidth, inner.height), "›", CrumbSeparatorStyle); + var rect = new Rect(x, inner.y, CrumbSeparatorWidth, inner.height); + var e = Event.current; + bool hover = rect.Contains(e.mousePosition); + if (e.type == EventType.Repaint && hover) + EditorGUI.DrawRect(rect, CrumbHoverColor); + GUI.Label(rect, new GUIContent("›", "Subfolders of " + parentPath), CrumbSeparatorStyle); + if (e.type == EventType.MouseDown && e.button == 0 && hover) + { + e.Use(); + ShowSubfolderMenu(rect, parentPath, currentChildPath); + } return x + CrumbSeparatorWidth; } + void ShowSubfolderMenu(Rect dropRect, string parentPath, string currentChildPath) + { + var menu = new GenericMenu(); + var subfolders = AssetDatabase.GetSubFolders(parentPath); + if (subfolders.Length == 0) + menu.AddDisabledItem(new GUIContent("No subfolders")); + foreach (var sub in subfolders) + { + var path = ProjectPaths.Normalize(sub); + menu.AddItem(new GUIContent(ProjectPaths.GetDisplayName(path)), + path == currentChildPath, () => NavigateTo(path)); + } + menu.DropDown(dropRect); + } + float DrawCrumb((string name, string path) crumb, bool isCurrent, float x, Rect inner) { var style = isCurrent ? CrumbCurrentStyle : CrumbStyle; diff --git a/Editor/Window/TabstepShelfWindow.cs b/Editor/Window/TabstepShelfWindow.cs new file mode 100644 index 0000000..b50aa6d --- /dev/null +++ b/Editor/Window/TabstepShelfWindow.cs @@ -0,0 +1,343 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace Yozolab.Tabstep +{ + /// + /// The Tabstep Shelf: a small borderless float window that parks objects mid-flight. + /// Drop assets (or scene objects) on it, then drag them back out onto another tab, + /// an Inspector object field, the scene — anywhere Unity drag & drop reaches. + /// + /// Shown with ShowPopup so it never steals focus while switching tabs underneath it. + /// Items survive domain reloads via window serialization; the editor-restart ghost + /// a popup window would otherwise leave behind is detected via SessionState (which + /// is cleared on restart, matching the shelf's "temporary" semantics) and closed. + /// + /// With the One-Shot preference on, dragging an item out consumes it: the item + /// disappears when the drag leaves the shelf and comes back only if the same drag + /// re-enters and drops it back. + /// + class TabstepShelfWindow : EditorWindow + { + const string DragKey = "Tabstep.ShelfDrag"; + const string AliveKey = "Tabstep.ShelfAlive"; + const float RowHeight = 22f; + static readonly Vector2 DefaultSize = new Vector2(230, 190); + + static TabstepShelfWindow _instance; + + [SerializeField] List _items = new List(); + [SerializeField] bool _keepOpen; + + Vector2 _scroll; + ShelfItem _mouseDownItem; + // One-shot bookkeeping: the item consumed by the current drag-out, restored + // if the very same drag re-enters the shelf and drops. + ShelfItem _lastDraggedOut; + + public static TabstepShelfWindow Instance + { + get + { + if (_instance == null) + { + var all = Resources.FindObjectsOfTypeAll(); + if (all.Length > 0) _instance = all[0]; + } + return _instance; + } + } + + public static bool IsOpen => Instance != null; + + /// A pinned shelf stays open when its Tabstep window closes. + public bool KeepOpen => _keepOpen; + + [MenuItem("YozoLab/Tabstep Shelf")] + static void OpenFromMenu() + { + var anchors = Resources.FindObjectsOfTypeAll(); + ShowNear(anchors.Length > 0 ? anchors[0] : null); + } + + public static TabstepShelfWindow ShowNear(EditorWindow anchor) + { + var window = Instance; + if (window == null) + { + window = CreateInstance(); + window.PositionNear(anchor); + window.ShowPopup(); + SessionState.SetBool(AliveKey, true); + } + window.Repaint(); + return window; + } + + public static void Toggle(EditorWindow anchor) + { + if (IsOpen) Instance.Close(); + else ShowNear(anchor); + } + + public void AddObjects(IEnumerable objects) + { + foreach (var obj in objects) + { + var item = ShelfItem.ForObject(obj); + if (item == null || _items.Exists(existing => existing.Key == item.Key)) continue; + _items.Add(item); + } + Repaint(); + } + + void OnEnable() + { + _instance = this; + titleContent = new GUIContent("Tabstep Shelf"); + minSize = new Vector2(160, 100); + wantsMouseMove = true; + // A popup re-created from a saved layout at editor startup is a ghost: + // SessionState is empty again, while a domain reload would have kept it. + if (!SessionState.GetBool(AliveKey, false)) + EditorApplication.delayCall += () => + { + if (this != null) Close(); + }; + } + + void OnDisable() + { + if (_instance == this) _instance = null; + } + + void OnDestroy() + { + SessionState.SetBool(AliveKey, false); + } + + void PositionNear(EditorWindow anchor) + { + var main = EditorGUIUtility.GetMainWindowPosition(); + var around = anchor != null ? anchor.position : main; + var pos = new Vector2(around.xMax - DefaultSize.x - 12, around.yMax - DefaultSize.y - 12); + pos.x = Mathf.Clamp(pos.x, main.x, Mathf.Max(main.x, main.xMax - DefaultSize.x)); + pos.y = Mathf.Clamp(pos.y, main.y, Mathf.Max(main.y, main.yMax - DefaultSize.y)); + position = new Rect(pos, DefaultSize); + } + + void OnGUI() + { + HandleDragEvents(); + DrawHeader(); + DrawItems(); + DrawBorder(); + } + + // ---- chrome ------------------------------------------------------------ + + /// Title row. A popup has no OS title bar, so this row also moves the window. + void DrawHeader() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + GUILayout.Label("Shelf", EditorStyles.boldLabel, GUILayout.ExpandWidth(false)); + GUILayout.FlexibleSpace(); + _keepOpen = GUILayout.Toggle(_keepOpen, + new GUIContent("Pin", "Keep the shelf open when the Tabstep window closes"), + EditorStyles.toolbarButton, GUILayout.ExpandWidth(false)); + using (new EditorGUI.DisabledScope(_items.Count == 0)) + if (GUILayout.Button(new GUIContent("Clear", "Remove all items"), + EditorStyles.toolbarButton, GUILayout.ExpandWidth(false))) + _items.Clear(); + if (GUILayout.Button(new GUIContent("×", "Close the shelf"), + EditorStyles.toolbarButton, GUILayout.Width(22))) + { + Close(); + GUIUtility.ExitGUI(); + } + EditorGUILayout.EndHorizontal(); + + var headerRect = GUILayoutUtility.GetLastRect(); + var e = Event.current; + // Dragging the empty part of the header moves the popup window. + if (e.type == EventType.MouseDrag && e.button == 0 && _mouseDownItem == null && + headerRect.Contains(e.mousePosition - e.delta)) + { + position = new Rect(position.position + e.delta, position.size); + e.Use(); + } + } + + /// Popup windows have no frame; a 1px outline keeps the shelf readable. + void DrawBorder() + { + if (Event.current.type != EventType.Repaint) return; + var color = EditorGUIUtility.isProSkin ? new Color(0.1f, 0.1f, 0.1f) : new Color(0.4f, 0.4f, 0.4f); + var full = new Rect(0, 0, position.width, position.height); + EditorGUI.DrawRect(new Rect(full.x, full.y, full.width, 1), color); + EditorGUI.DrawRect(new Rect(full.x, full.yMax - 1, full.width, 1), color); + EditorGUI.DrawRect(new Rect(full.x, full.y, 1, full.height), color); + EditorGUI.DrawRect(new Rect(full.xMax - 1, full.y, 1, full.height), color); + } + + // ---- items ------------------------------------------------------------- + + void DrawItems() + { + if (_items.Count == 0) + { + EditorGUILayout.Space(10); + EditorGUILayout.LabelField("Drop assets or scene objects here,\n" + + "then drag them out anywhere —\n" + + "other tabs, Inspector fields, the scene.", + EditorStyles.centeredGreyMiniLabel, GUILayout.Height(50)); + return; + } + + _scroll = EditorGUILayout.BeginScrollView(_scroll); + for (int i = _items.Count - 1; i >= 0; i--) + { + // Newest first; DrawItem returns false when the item was removed. + if (!DrawItem(_items[i])) + { + EditorGUILayout.EndScrollView(); + GUIUtility.ExitGUI(); + return; + } + } + EditorGUILayout.EndScrollView(); + } + + bool DrawItem(ShelfItem item) + { + var rect = GUILayoutUtility.GetRect(GUIContent.none, GUIStyle.none, + GUILayout.ExpandWidth(true), GUILayout.Height(RowHeight)); + var removeRect = new Rect(rect.xMax - 20, rect.y + (rect.height - 16) / 2, 16, 16); + var obj = item.Resolve(); + bool alive = obj != null; + + var e = Event.current; + if (e.type == EventType.Repaint) + { + if (rect.Contains(e.mousePosition)) + EditorGUI.DrawRect(rect, new Color(0.5f, 0.7f, 1f, 0.15f)); + var icon = alive ? AssetPreview.GetMiniThumbnail(obj) : null; + if (icon != null) + GUI.DrawTexture(new Rect(rect.x + 4, rect.y + (rect.height - 16) / 2, 16, 16), + icon, ScaleMode.ScaleToFit); + var labelRect = new Rect(rect.x + 24, rect.y, rect.width - 48, rect.height); + var style = alive ? EditorStyles.label : EditorStyles.centeredGreyMiniLabel; + var label = alive ? item.DisplayName : item.DisplayName + " (missing)"; + GUI.Label(labelRect, new GUIContent(label, item.AssetPath ?? label), style); + } + if (e.type == EventType.MouseMove && rect.Contains(e.mousePosition)) + Repaint(); + + if (GUI.Button(removeRect, new GUIContent("×", "Remove from the shelf"), EditorStyles.miniLabel)) + { + _items.Remove(item); + return false; + } + + switch (e.type) + { + case EventType.MouseDown when e.button == 0 && rect.Contains(e.mousePosition): + _mouseDownItem = item; + e.Use(); + break; + case EventType.MouseUp: + _mouseDownItem = null; + break; + case EventType.MouseDrag when _mouseDownItem == item && alive: + _mouseDownItem = null; + _lastDraggedOut = null; + DragAndDrop.PrepareStartDrag(); + DragAndDrop.objectReferences = new[] { obj }; + var path = item.AssetPath; + if (path != null) DragAndDrop.paths = new[] { path }; + DragAndDrop.SetGenericData(DragKey, item.Key); + DragAndDrop.StartDrag(item.DisplayName); + e.Use(); + break; + } + return true; + } + + // ---- drag & drop in/out -------------------------------------------------- + + void HandleDragEvents() + { + var e = Event.current; + switch (e.type) + { + case EventType.DragUpdated: + case EventType.DragPerform: + { + var key = DragAndDrop.GetGenericData(DragKey) as string; + if (key != null && _lastDraggedOut != null && _lastDraggedOut.Key == key) + { + // The one-shot drag came back: let it drop to restore the item. + DragAndDrop.visualMode = DragAndDropVisualMode.Move; + if (e.type == EventType.DragPerform) + { + DragAndDrop.AcceptDrag(); + _items.Add(_lastDraggedOut); + _lastDraggedOut = null; + e.Use(); + // The row count changed mid-pass; abort before the stale layout draws. + GUIUtility.ExitGUI(); + } + e.Use(); + break; + } + if (key != null) + { + // Our own item hovering its own shelf — nothing to do with it here. + DragAndDrop.visualMode = DragAndDropVisualMode.Rejected; + e.Use(); + break; + } + if (DragAndDrop.objectReferences.Length == 0) break; + DragAndDrop.visualMode = DragAndDropVisualMode.Copy; + if (e.type == EventType.DragPerform) + { + DragAndDrop.AcceptDrag(); + AddObjects(DragAndDrop.objectReferences); + e.Use(); + // The row count changed mid-pass; abort before the stale layout draws. + GUIUtility.ExitGUI(); + } + e.Use(); + break; + } + case EventType.DragExited: + // Our drag left the shelf (heading for its target). With One-Shot on, + // that consumes the item — it returns only if the drag re-enters. + // A drag released inside the shelf (a cancelled pick-up) is not an exit. + if (!new Rect(0, 0, position.width, position.height).Contains(e.mousePosition)) + ConsumeDraggedOutItem(); + break; + } + } + + void ConsumeDraggedOutItem() + { + if (!TabstepSettings.ShelfOneShot) return; + var key = DragAndDrop.GetGenericData(DragKey) as string; + if (key == null) return; + var item = _items.Find(existing => existing.Key == key); + if (item == null) return; + _items.Remove(item); + _lastDraggedOut = item; + Repaint(); + // An emptied unpinned shelf folds away once the drag has finished. + if (_items.Count == 0 && !_keepOpen) + EditorApplication.delayCall += () => + { + if (this != null && _items.Count == 0 && !_keepOpen) Close(); + }; + } + } +} diff --git a/Editor/Window/TabstepShelfWindow.cs.meta b/Editor/Window/TabstepShelfWindow.cs.meta new file mode 100644 index 0000000..945b3e7 --- /dev/null +++ b/Editor/Window/TabstepShelfWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d454bdb61ba3487b890f7c1849686f2f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md index 806261f..6b33795 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,28 @@ search, thumbnails, drag & drop, renaming and context menus all behave exactly like the built-in Project window. On top of it, Tabstep adds: - **Tabs** — open any number of folders side by side. Tabs (including their - history) survive domain reloads and editor restarts. + history) survive domain reloads and editor restarts. Drag tab headers to + reorder them; tabs of same-named folders show their parent name too + (`Scripts — Player`), and the `▾` button at the right end lists every tab. +- **Pinned tabs** — right-click a tab to pin it: it moves to the left edge, + shrinks to its folder icon and ignores Ctrl+W / middle-click (closing it + explicitly via the context menu still works). Close Others / Close to the + Right spare pinned tabs. +- **Reopen closed tab** — `Ctrl+Shift+T` (or the tab context menu) brings back + the last closed tab with its full history; the last 10 are remembered. - **Back / forward history per tab** — navigate like a web browser, including - with the mouse side (thumb) buttons. + with the mouse side (thumb) buttons. Right-click ◀ / ▶ for the full history + as a dropdown. +- **Per-tab search** — each tab remembers its own search filter and restores + it when the tab becomes active again. +- **Quick Access** — right-click the `+` button for bookmarked folders; add + the current folder from there or from a tab's context menu. Stored per user + and per project in `UserSettings/`. - **Explorer-style address bar** — back/forward/up buttons, the folder icon and the current path as clickable breadcrumb segments in a sunken field (hover highlights them; parents that don't fit collapse behind `«`). Click any - segment to jump there. + segment to jump there, or click the `›` between segments to pick one of that + folder's subfolders, exactly like Explorer's chevrons. - **One single bar instead of three** — with [Harmony](#harmony-optional-but-recommended) available, the embedded browser's own toolbar (create button + search field) and its `Assets > ...` path header are removed entirely; the create button @@ -41,6 +56,26 @@ like the built-in Project window. On top of it, Tabstep adds: them as new tabs. - **`Assets > Open in Tabstep`** — open the selected folder in a new tab. +## Tabstep Shelf + +A small floating tray for objects in transit (`Shelf` button in the navigation +bar, `Ctrl+Shift+D`, or `YozoLab > Tabstep Shelf`). Park assets or scene +objects on it, then drag them back out **anywhere Unity drag & drop reaches**: +another tab's folder, an Inspector object field, the Hierarchy, the Scene view. + +- **Getting things onto it** — drop them on the shelf window, drop them on the + `▼ Shelf` zone that appears at the right end of the tab bar while you drag + assets, or use *Send Selection to Shelf* in a tab's context menu. +- **One-shot by default** — dragging an item out consumes it: the shelf is a + hand-off, not a copy source. Dragging it back before dropping restores it; + the behaviour is toggleable in preferences. +- **Stays out of the way** — shown as a borderless popup that never steals + focus, so you can switch tabs underneath it mid-drag. Drag its title row to + move it. It survives domain reloads, closes with its Tabstep window (unless + pinned with the `Pin` button), and an emptied unpinned shelf closes itself. +- Items are stored as GUIDs / GlobalObjectIds, so they survive domain reloads; + scene objects resolve while their scene is open and grey out otherwise. + ## Tabstep Inspector (parked) A tabbed Inspector also exists in the codebase, but it is **temporarily @@ -53,23 +88,31 @@ without that define none of it is compiled, registered or shown. Open via `YozoLab > Tabstep`. -| Shortcut | Action | -| ----------------------------- | ----------------------------------------------------------------- | -| `Ctrl+T` | New tab | -| `Ctrl+W` | Close tab (closing the last tab closes the window) | -| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Next / previous tab | -| `Alt+←` / `Alt+→` | Back / forward | -| Mouse side buttons | Back / forward | -| `Alt+↑` | Parent folder | -| `Ctrl+L` / `Alt+D` | Edit the path (`Enter` to go, `Esc` to cancel) | -| `Ctrl+F` | Focus the search field (when it lives in the navigation bar) | -| Middle-click tab | Close tab | -| Right-click tab | Close others / close to the right / duplicate / copy & paste path | -| Right-click path bar | Copy path / copy absolute path / paste path / edit path | +| Shortcut | Action | +| ----------------------------- | ---------------------------------------------------------------------------- | +| `Ctrl+T` | New tab | +| `Ctrl+W` | Close tab (closing the last tab closes the window; pinned tabs are immune) | +| `Ctrl+Shift+T` | Reopen the last closed tab | +| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Next / previous tab | +| `Ctrl+1` … `Ctrl+8` | Jump to that tab | +| `Ctrl+9` | Jump to the last tab | +| `Alt+←` / `Alt+→` | Back / forward | +| Mouse side buttons | Back / forward | +| `Alt+↑` | Parent folder | +| `Ctrl+L` / `Alt+D` | Edit the path (`Enter` to go, `Esc` to cancel) | +| `Ctrl+F` | Focus the search field (when it lives in the navigation bar) | +| `Ctrl+Shift+C` | Copy the current folder's absolute path | +| `Ctrl+Shift+D` | Toggle the shelf | +| Drag tab | Reorder tabs | +| Middle-click tab | Close tab | +| Right-click tab | Pin / quick access / shelf / close others / duplicate / copy & paste path | +| Right-click `+` | Quick Access menu | +| Right-click `◀` / `▶` | Full history dropdown | +| Right-click path bar | Copy path / copy absolute path / paste path / edit path | Preferences live under `Edit > Preferences > Yozolab > Tabstep` -(new-tab folder, navigation bar, tab title length, middle-click close, -mouse side buttons, ping behaviour). +(new-tab folder, new-tab position, navigation bar, tab title length, +middle-click close, mouse side buttons, ping behaviour, shelf one-shot). ## Harmony (optional but recommended) @@ -97,8 +140,8 @@ navigation bar is enabled; regular Project windows are never touched. ## Notes / limitations -- The embedded browser is shared between tabs, so the search text and scroll - position are not kept per tab — only the folder and its history are. +- The embedded browser is shared between tabs. The folder, its history and the + search text are kept per tab; the scroll position is not. - While searching, a search-scope header row appears above the results, just like in the stock Project window; clearing the search removes it again. - The search field in the navigation bar accepts the stock filter syntax diff --git a/Tests/TabSessionTests.cs b/Tests/TabSessionTests.cs index fe4ebdd..05e10d8 100644 --- a/Tests/TabSessionTests.cs +++ b/Tests/TabSessionTests.cs @@ -117,5 +117,151 @@ public void DuplicateTab_InsertsIndependentCopyAfterSource() copy.Navigate("Assets/C"); Assert.AreEqual("Assets/A", session.Tabs[0].CurrentPath); } + + [Test] + public void Move_Reorders_AndKeepsTheActiveTabActive() + { + var session = SessionWith("Assets", "Assets/A", "Assets/B"); + session.Activate(0); + Assert.IsTrue(session.Move(0, 2)); + CollectionAssert.AreEqual(new[] { "Assets/A", "Assets/B", "Assets" }, + TabPaths(session)); + Assert.AreEqual("Assets", session.ActiveTab.CurrentPath); + Assert.AreEqual(2, session.ActiveIndex); + } + + [Test] + public void MoveTab_PinnedTab_StaysInsidePinnedRegion() + { + var session = SessionWith("Assets", "Assets/A", "Assets/B"); + session.SetPinned(0, true); + session.SetPinned(1, true); + // Trying to drag the first pinned tab past the unpinned region clamps it. + Assert.AreEqual(1, session.MoveTab(0, 2)); + Assert.IsTrue(session.Tabs[0].Pinned); + Assert.IsTrue(session.Tabs[1].Pinned); + Assert.IsFalse(session.Tabs[2].Pinned); + } + + [Test] + public void MoveTab_UnpinnedTab_CannotEnterPinnedRegion() + { + var session = SessionWith("Assets", "Assets/A", "Assets/B"); + session.SetPinned(0, true); + Assert.AreEqual(1, session.MoveTab(2, 0)); + CollectionAssert.AreEqual(new[] { "Assets", "Assets/B", "Assets/A" }, + TabPaths(session)); + } + + [Test] + public void SetPinned_MovesTheTabToThePinnedRegion() + { + var session = SessionWith("Assets", "Assets/A", "Assets/B"); + session.SetPinned(2, true); + Assert.AreEqual("Assets/B", session.Tabs[0].CurrentPath); + Assert.IsTrue(session.Tabs[0].Pinned); + Assert.AreEqual(1, session.PinnedCount); + + session.SetPinned(0, false); + Assert.IsFalse(session.Tabs[0].Pinned); + Assert.AreEqual(0, session.PinnedCount); + } + + [Test] + public void CloseTab_IsRemembered_AndReopens_WithHistory() + { + var session = SessionWith("Assets", "Assets/A"); + session.Tabs[1].Navigate("Assets/A/B"); + session.CloseTab(1); + Assert.AreEqual(1, session.Count); + Assert.IsTrue(session.HasClosedTabs); + + var reopened = session.ReopenClosedTab(); + Assert.IsNotNull(reopened); + Assert.AreSame(reopened, session.ActiveTab); + Assert.AreEqual("Assets/A/B", reopened.CurrentPath); + Assert.IsTrue(reopened.CanGoBack); + Assert.IsFalse(session.HasClosedTabs); + } + + [Test] + public void ReopenClosedTab_WithoutHistory_ReturnsNull() + { + var session = SessionWith("Assets"); + Assert.IsNull(session.ReopenClosedTab()); + } + + [Test] + public void ReopenClosedTab_NeverComesBackPinned() + { + var session = SessionWith("Assets", "Assets/A"); + session.SetPinned(1, true); // moves the pinned tab to index 0 + session.CloseTab(0); // explicit close works even on a pinned tab + var reopened = session.ReopenClosedTab(); + Assert.IsFalse(reopened.Pinned); + } + + [Test] + public void RecentlyClosed_IsCapped() + { + var session = SessionWith("Assets"); + for (int i = 0; i < TabSession.MaxClosedTabs + 5; i++) + { + session.OpenTab($"Assets/F{i}"); + session.CloseTab(session.Count - 1); + } + for (int i = 0; i < TabSession.MaxClosedTabs; i++) + Assert.IsNotNull(session.ReopenClosedTab()); + Assert.IsNull(session.ReopenClosedTab()); + } + + [Test] + public void CloseOthers_SparesPinnedTabs() + { + var session = SessionWith("Assets", "Assets/A", "Assets/B", "Assets/C"); + session.SetPinned(0, true); + session.CloseOthers(3); + CollectionAssert.AreEqual(new[] { "Assets", "Assets/C" }, TabPaths(session)); + Assert.AreEqual("Assets/C", session.ActiveTab.CurrentPath); + } + + [Test] + public void CloseToRight_SparesPinnedTabs() + { + var session = SessionWith("Assets", "Assets/A", "Assets/B", "Assets/C"); + session.SetPinned(3, true); // moves Assets/C to the front + session.CloseToRight(1); + CollectionAssert.AreEqual(new[] { "Assets/C", "Assets" }, TabPaths(session)); + } + + [Test] + public void DuplicateTab_OfPinnedTab_LandsUnpinnedAfterThePinnedRegion() + { + var session = SessionWith("Assets", "Assets/A"); + session.SetPinned(0, true); + var copy = session.DuplicateTab(0); + Assert.IsFalse(copy.Pinned); + Assert.AreEqual(1, session.ActiveIndex); + Assert.AreEqual(1, session.PinnedCount); + } + + [Test] + public void OpenTabAfterActive_InsertsNextToTheActiveTab() + { + var session = SessionWith("Assets", "Assets/A", "Assets/B"); + session.Activate(0); + var opened = session.OpenTabAfterActive("Assets/C"); + Assert.AreSame(opened, session.ActiveTab); + CollectionAssert.AreEqual(new[] { "Assets", "Assets/C", "Assets/A", "Assets/B" }, + TabPaths(session)); + } + + static string[] TabPaths(TabSession session) + { + var paths = new string[session.Count]; + for (int i = 0; i < session.Count; i++) + paths[i] = session.Tabs[i].CurrentPath; + return paths; + } } } diff --git a/Tests/TabStateTests.cs b/Tests/TabStateTests.cs index 77e1547..2ee9347 100644 --- a/Tests/TabStateTests.cs +++ b/Tests/TabStateTests.cs @@ -106,5 +106,44 @@ public void Clone_IsIndependent() Assert.AreEqual("Assets/B", copy.CurrentPath); Assert.AreEqual(2, tab.History.Count); } + + [Test] + public void Clone_CopiesSearchText_ButNotThePin() + { + var tab = new TabState("Assets") { Pinned = true, SearchText = "t:Texture" }; + var copy = tab.Clone(); + Assert.AreEqual("t:Texture", copy.SearchText); + Assert.IsFalse(copy.Pinned); + } + + [Test] + public void GoToHistoryIndex_JumpsWithoutLosingForwardHistory() + { + var tab = new TabState("Assets"); + tab.Navigate("Assets/A"); + tab.Navigate("Assets/A/B"); + + tab.GoToHistoryIndex(0); + Assert.AreEqual("Assets", tab.CurrentPath); + Assert.IsTrue(tab.CanGoForward); + Assert.AreEqual(3, tab.History.Count); + } + + [Test] + public void GoToHistoryIndex_IgnoresOutOfRange() + { + var tab = new TabState("Assets"); + tab.Navigate("Assets/A"); + tab.GoToHistoryIndex(5); + tab.GoToHistoryIndex(-1); + Assert.AreEqual("Assets/A", tab.CurrentPath); + } + + [Test] + public void SearchText_NeverNull() + { + var tab = new TabState("Assets") { SearchText = null }; + Assert.AreEqual("", tab.SearchText); + } } } diff --git a/package.json b/package.json index c5aeeb9..31a4920 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "net.yozolab.tabstep", - "version": "0.1.2", + "version": "0.1.3", "displayName": "Tabstep", "description": "Tabbed Project window for Unity. Hosts the stock Project browser and adds Windows Explorer style tabs, back/forward history, breadcrumbs and keyboard navigation.", "unity": "2022.3", From 33b65b89a1a03d5ae97297b7c6e3de18d926701c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:01:45 +0000 Subject: [PATCH 2/8] feat: navigation, search, shelf and workflow power-ups Navigation: - Path autocomplete while editing (Ctrl+L): subfolder dropdown, Tab completes, arrows select, Enter goes, click jumps (middle-click: new tab) - Middle-click back/forward/up buttons and breadcrumb segments opens the destination in a new tab - Hovering back/forward/up with a drag in flight spring-loads the navigation, stepping repeatedly so a drag can walk through the history - Start page: a fresh tab shows Quick Access, saved searches and recently closed tabs instead of jumping to a folder (toggleable) Search: - One-click t: filter chips next to the search field (configurable list) - Saved searches in Quick Access: a filter pinned to its folder, reopening as a ready-filtered tab Workflow: - Workspaces: save the whole tab set (order, pins, histories, searches) under a name and restore it from the tab-list dropdown - Undo Last Asset Move in the tab and path-bar context menus - Status bar: folder item count plus selection count and file size Shelf: - Drag-all handle carries every item out in one drag (one-shot aware) - 'Move to Tab' hands all asset items to the active tab's folder - Per-item context menu: ping, select, remove, and component relay - paste a shelved component onto the selected GameObjects --- Editor/Model/TabSession.cs | 16 +- Editor/TabstepSettings.cs | 37 ++ Editor/Utils/TabstepBookmarks.cs | 45 +- Editor/Utils/TabstepWorkspaces.cs | 113 ++++ Editor/Utils/TabstepWorkspaces.cs.meta | 11 + Editor/Window/TabstepProjectWindow.cs | 811 +++++++++++++++++++++++-- Editor/Window/TabstepShelfWindow.cs | 163 ++++- README.md | 37 +- Tests/SearchTokenTests.cs | 46 ++ Tests/SearchTokenTests.cs.meta | 11 + Tests/TabSessionTests.cs | 32 + 11 files changed, 1257 insertions(+), 65 deletions(-) create mode 100644 Editor/Utils/TabstepWorkspaces.cs create mode 100644 Editor/Utils/TabstepWorkspaces.cs.meta create mode 100644 Tests/SearchTokenTests.cs create mode 100644 Tests/SearchTokenTests.cs.meta diff --git a/Editor/Model/TabSession.cs b/Editor/Model/TabSession.cs index c55f67c..69cbdee 100644 --- a/Editor/Model/TabSession.cs +++ b/Editor/Model/TabSession.cs @@ -20,6 +20,9 @@ class TabSession : TabCollection public bool HasClosedTabs => _recentlyClosed.Count > 0; + /// Most recently closed last; read-only peek for the start page. + public IReadOnlyList RecentlyClosed => _recentlyClosed; + public TabState OpenTab(string folderPath, bool activate = true) { return Add(new TabState(folderPath), activate); @@ -28,8 +31,17 @@ public TabState OpenTab(string folderPath, bool activate = true) /// Opens a tab right after the active one (never inside the pinned region). public TabState OpenTabAfterActive(string folderPath) { - if (ActiveIndex < 0) return OpenTab(folderPath); - return InsertAfter(Math.Max(ActiveIndex, PinnedCount - 1), new TabState(folderPath)); + return AddTab(new TabState(folderPath), besideActive: true); + } + + /// + /// 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. + /// + public TabState AddTab(TabState tab, bool besideActive) + { + if (!besideActive || ActiveIndex < 0) return Add(tab); + return InsertAfter(Math.Max(ActiveIndex, PinnedCount - 1), tab); } /// Inserts a copy (including history) right after the source and activates it. diff --git a/Editor/TabstepSettings.cs b/Editor/TabstepSettings.cs index 76e55ca..85f6220 100644 --- a/Editor/TabstepSettings.cs +++ b/Editor/TabstepSettings.cs @@ -29,6 +29,27 @@ public static bool NewTabBesideActive set => EditorPrefs.SetBool(Prefix + "NewTabBesideActive", value); } + /// A fresh tab (Ctrl+T) shows the start page instead of jumping to a folder. + public static bool NewTabStartPage + { + get => EditorPrefs.GetBool(Prefix + "NewTabStartPage", true); + set => EditorPrefs.SetBool(Prefix + "NewTabStartPage", value); + } + + /// Status bar at the bottom: folder item count and selection summary. + public static bool ShowStatusBar + { + get => EditorPrefs.GetBool(Prefix + "ShowStatusBar", true); + set => EditorPrefs.SetBool(Prefix + "ShowStatusBar", value); + } + + /// Comma-separated type names shown as one-click search filter chips; empty hides them. + public static string SearchChips + { + get => EditorPrefs.GetString(Prefix + "SearchChips", "Prefab,Scene,Material,Script"); + set => EditorPrefs.SetString(Prefix + "SearchChips", value); + } + /// Dragging an item out of the shelf consumes it (it is a hand-off, not a copy source). public static bool ShelfOneShot { @@ -75,6 +96,7 @@ public static void ResetAll() { "NewTabFolder", "MiddleClickClosesTab", "ShowNavigationBar", "MaxTabTitleLength", "PingOpensNewTab", "MouseSideButtonsNavigate", "NewTabBesideActive", "ShelfOneShot", + "NewTabStartPage", "ShowStatusBar", "SearchChips", }; foreach (var key in keys) EditorPrefs.DeleteKey(Prefix + key); @@ -118,6 +140,11 @@ static void DrawGui() new GUIContent("Open New Tab Beside Active", "New tabs open right of the active tab instead of at the end of the bar."), TabstepSettings.NewTabBesideActive); + TabstepSettings.NewTabStartPage = EditorGUILayout.Toggle( + new GUIContent("New Tab Shows Start Page", + "A fresh tab (Ctrl+T) shows Quick Access, saved searches and recently " + + "closed tabs instead of jumping straight to a folder."), + TabstepSettings.NewTabStartPage); TabstepSettings.MaxTabTitleLength = EditorGUILayout.IntSlider( new GUIContent("Max Tab Title Length"), TabstepSettings.MaxTabTitleLength, 4, 60); @@ -133,6 +160,16 @@ static void DrawGui() "absorbs its toolbar (create + search) too. When off, the stock browser " + "chrome is shown instead."), TabstepSettings.ShowNavigationBar); + TabstepSettings.ShowStatusBar = EditorGUILayout.Toggle( + new GUIContent("Status Bar", + "A bottom row showing the current folder's item count and a summary of " + + "the selected assets (count and file size)."), + TabstepSettings.ShowStatusBar); + TabstepSettings.SearchChips = EditorGUILayout.TextField( + new GUIContent("Search Filter Chips", + "Comma-separated type names shown as one-click t: filter buttons next to " + + "the search field (e.g. \"Prefab,Scene,Material,Script\"). Empty hides them."), + TabstepSettings.SearchChips); EditorGUI.indentLevel--; EditorGUILayout.Space(8); diff --git a/Editor/Utils/TabstepBookmarks.cs b/Editor/Utils/TabstepBookmarks.cs index 7970102..180b036 100644 --- a/Editor/Utils/TabstepBookmarks.cs +++ b/Editor/Utils/TabstepBookmarks.cs @@ -5,10 +5,19 @@ namespace Yozolab.Tabstep { + /// A bookmarked search: a filter pinned to the folder it was made in. + [Serializable] + class SavedSearch + { + public string folder; + public string search; + } + /// - /// The Quick Access folder list (right-click on the + tab button). Stored per - /// user and per project in UserSettings/ — not in EditorPrefs — because the - /// paths only mean something inside this project. + /// The Quick Access lists (right-click on the + tab button): folders, plus saved + /// searches that reopen as a tab with their filter applied. Stored per user and + /// per project in UserSettings/ — not in EditorPrefs — because the paths only + /// mean something inside this project. /// static class TabstepBookmarks { @@ -16,6 +25,7 @@ static class TabstepBookmarks class Payload { public List folders = new List(); + public List searches = new List(); } static Payload _payload; // cache; dropped on domain reload, reloaded lazily @@ -52,6 +62,35 @@ public static void Remove(string folderPath) Save(); } + public static IReadOnlyList Searches => Load().searches; + + public static bool ContainsSearch(string folderPath, string search) + { + return FindSearch(folderPath, search) != null; + } + + public static void AddSearch(string folderPath, string search) + { + folderPath = ProjectPaths.Normalize(folderPath); + if (folderPath == null || string.IsNullOrWhiteSpace(search)) return; + if (FindSearch(folderPath, search) != null) return; + Load().searches.Add(new SavedSearch { folder = folderPath, search = search.Trim() }); + Save(); + } + + public static void RemoveSearch(SavedSearch entry) + { + if (Load().searches.Remove(entry)) + Save(); + } + + static SavedSearch FindSearch(string folderPath, string search) + { + folderPath = ProjectPaths.Normalize(folderPath); + search = search?.Trim(); + return Load().searches.Find(s => s.folder == folderPath && s.search == search); + } + static Payload Load() { if (_payload != null) return _payload; diff --git a/Editor/Utils/TabstepWorkspaces.cs b/Editor/Utils/TabstepWorkspaces.cs new file mode 100644 index 0000000..9ff570c --- /dev/null +++ b/Editor/Utils/TabstepWorkspaces.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEngine; + +namespace Yozolab.Tabstep +{ + /// + /// Named tab sets ("UI work", "character work"...) that can be saved and loaded + /// from the tab-list dropdown. A workspace is a deep copy of a whole + /// — tabs, order, pins, histories and searches included. + /// Stored per user and per project in UserSettings/. + /// + static class TabstepWorkspaces + { + [Serializable] + class Workspace + { + public string name; + public TabSession session; + } + + [Serializable] + class Payload + { + public List workspaces = new List(); + } + + static Payload _payload; // cache; dropped on domain reload, reloaded lazily + + static string FilePath + { + get + { + var projectRoot = Path.GetDirectoryName(Path.GetFullPath(Application.dataPath)); + return Path.Combine(projectRoot, "UserSettings", "TabstepWorkspaces.json"); + } + } + + public static IReadOnlyList Names + { + get + { + var names = new List(); + foreach (var workspace in Load().workspaces) + names.Add(workspace.name); + return names; + } + } + + /// Saves a deep copy of the session, replacing a workspace of the same name. + public static void Save(string name, TabSession session) + { + name = name?.Trim(); + if (string.IsNullOrEmpty(name) || session == null) return; + var payload = Load(); + payload.workspaces.RemoveAll(w => w.name == name); + payload.workspaces.Add(new Workspace { name = name, session = DeepCopy(session) }); + SaveFile(); + } + + /// A fresh copy of the stored session, or null when the name is unknown. + public static TabSession Get(string name) + { + var workspace = Load().workspaces.Find(w => w.name == name); + return workspace?.session == null ? null : DeepCopy(workspace.session); + } + + public static void Delete(string name) + { + if (Load().workspaces.RemoveAll(w => w.name == name) > 0) + SaveFile(); + } + + /// Serialization round-trip — keeps stored sessions detached from live ones. + static TabSession DeepCopy(TabSession session) + { + return JsonUtility.FromJson(JsonUtility.ToJson(session)); + } + + static Payload Load() + { + if (_payload != null) return _payload; + _payload = new Payload(); + try + { + if (File.Exists(FilePath)) + { + var loaded = JsonUtility.FromJson(File.ReadAllText(FilePath)); + if (loaded?.workspaces != null) _payload = loaded; + } + } + catch (Exception e) + { + Debug.LogWarning($"[Tabstep] Could not read the workspace list: {e.Message}"); + } + return _payload; + } + + static void SaveFile() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)); + File.WriteAllText(FilePath, JsonUtility.ToJson(Load(), true)); + } + catch (Exception e) + { + Debug.LogWarning($"[Tabstep] Could not save the workspace list: {e.Message}"); + } + } + } +} diff --git a/Editor/Utils/TabstepWorkspaces.cs.meta b/Editor/Utils/TabstepWorkspaces.cs.meta new file mode 100644 index 0000000..d5ef173 --- /dev/null +++ b/Editor/Utils/TabstepWorkspaces.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9d371518f9764a4a8fafff03a85b6a86 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Window/TabstepProjectWindow.cs b/Editor/Window/TabstepProjectWindow.cs index c892c17..4ce4522 100644 --- a/Editor/Window/TabstepProjectWindow.cs +++ b/Editor/Window/TabstepProjectWindow.cs @@ -101,6 +101,42 @@ public class TabstepProjectWindow : EditorWindow bool _dragActive; bool _dragZoneVisible; + // Spring-loading the nav buttons: hovering ◀ ▶ ▲ with a drag in flight navigates + // after a moment, like Explorer — so a drag can walk back through the history. + int _navSpringTarget; + double _navSpringStart; + + // Path autocomplete while editing (Ctrl+L): subfolder candidates under the typed + // prefix. Events are handled before the embedded browser (so clicks win), the + // dropdown is painted after it (so it draws on top). + readonly List _pathSuggestions = new List(); + int _pathSuggestionIndex = -1; + string _pathSuggestionQuery; + Rect _pathFieldRect; + const float SuggestionRowHeight = 18f; + const int MaxPathSuggestions = 8; + + // Last completed asset move (drop on a tab header / shelf hand-off), so it can + // be undone from the context menus. Intentionally not serialized. + List<(string from, string to)> _lastMove = new List<(string, string)>(); + + // Status bar caches — folder listings and file sizes are not free, so they + // refresh on a timer instead of every repaint. + string _statusFolder; + int _statusItemCount; + double _statusFolderTime; + string _statusSelectionText = ""; + double _statusSelectionTime; + + Vector2 _startPageScroll; + bool _openWorkspacePopup; // "Save Tabs As..." defers the popup to the next OnGUI + + static GUIStyle _rightMiniLabel; + static GUIStyle RightMiniLabel => _rightMiniLabel ??= new GUIStyle(EditorStyles.miniLabel) + { + alignment = TextAnchor.MiddleRight, + }; + [MenuItem("YozoLab/Tabstep")] public static TabstepProjectWindow Open() { @@ -127,12 +163,27 @@ static string GetSelectedFolderPath() return AssetDatabase.IsValidFolder(path) ? path : null; } - /// Opens (or the default folder) as a new active tab. + /// + /// Opens as a new active tab. With no folder given, + /// the new tab is the start page (when enabled) or the default folder. + /// public void OpenInNewTab(string folderPath) { - var path = ValidFolderOrDefault(folderPath); - if (TabstepSettings.NewTabBesideActive) _session.OpenTabAfterActive(path); - else _session.OpenTab(path); + // An empty TabState (no history) is the start page; navigating fills it in. + var tab = folderPath == null && TabstepSettings.NewTabStartPage + ? new TabState() + : new TabState(ValidFolderOrDefault(folderPath)); + _session.AddTab(tab, TabstepSettings.NewTabBesideActive); + _applyTabToBrowser = true; + Repaint(); + } + + /// Opens a folder in a new tab with a search filter already applied. + void OpenSavedSearchInNewTab(string folderPath, string search) + { + var tab = _session.AddTab(new TabState(ValidFolderOrDefault(folderPath)), + TabstepSettings.NewTabBesideActive); + tab.SearchText = search; _applyTabToBrowser = true; Repaint(); } @@ -189,18 +240,32 @@ void OnGUI() float toolbarHeight = EditorStyles.toolbar.fixedHeight; bool showNav = TabstepSettings.ShowNavigationBar; + bool showStatus = TabstepSettings.ShowStatusBar; DrawTabBar(); if (showNav) DrawNavigationBar(); + // The suggestion dropdown overlaps the browser: its events must win before + // the browser runs, while its pixels must land after (drawn below). + HandlePathSuggestionEvents(); + // Computed identically in every IMGUI pass (never via GUILayoutUtility.GetRect, // whose dummy Layout-pass rect would feed the embedded browser a 1px layout). float top = toolbarHeight * (showNav ? 2 : 1); - var content = new Rect(0, top, position.width, position.height - top); + float statusHeight = showStatus ? 18f : 0f; + var content = new Rect(0, top, position.width, position.height - top - statusHeight); if (content.height > 0) - // The navigation bar replaces the browser's path header (and, when the - // Harmony patches are active, its whole toolbar) — only while it's shown, - // so a path display and search always remain available. - _host.OnGUI(content, showNav); + { + if (_session.ActiveTab != null && _session.ActiveTab.CurrentPath == null) + DrawStartPage(content); + else + // The navigation bar replaces the browser's path header (and, when the + // Harmony patches are active, its whole toolbar) — only while it's shown, + // so a path display and search always remain available. + _host.OnGUI(content, showNav); + } + if (showStatus) + DrawStatusBar(new Rect(0, position.height - statusHeight, position.width, statusHeight)); + DrawPathSuggestions(); } // ---- browser <-> tab sync -------------------------------------------- @@ -213,6 +278,13 @@ void SyncWithBrowser() if (_applyTabToBrowser) { _applyTabToBrowser = false; + if (tab.CurrentPath == null) + { + // Start page: nothing to push to the browser. Baseline the observed + // path so a later external ping registers as a change (below). + _observedBrowserPath = _host.GetActiveFolderPath(); + return; + } if (!_host.ShowFolder(tab.CurrentPath)) { // Folder was deleted/renamed while the tab pointed at it. @@ -230,6 +302,19 @@ void SyncWithBrowser() return; } + if (tab.CurrentPath == null) + { + // Start page is showing. A ping / "Show in Project" from outside drove + // the hidden browser somewhere — adopt that folder into this empty tab. + var external = _host.GetActiveFolderPath(); + if (external != null && external != _observedBrowserPath) + { + _observedBrowserPath = external; + tab.Navigate(external); + } + return; + } + // Mirror the browser's live filter into the active tab (typed into either // search field, or cleared by the browser when a folder is clicked). var browserSearch = _host.GetSearchText(); @@ -330,7 +415,7 @@ void HandleShortcuts() } else if (ctrl && e.shift && e.keyCode == KeyCode.C) { - if (_session.ActiveTab != null) + if (_session.ActiveTab?.CurrentPath != null) { EditorGUIUtility.systemCopyBuffer = ToAbsolutePath(_session.ActiveTab.CurrentPath); ShowNotification(new GUIContent("Absolute path copied")); @@ -533,6 +618,7 @@ void CancelPathEdit() { _editingPath = false; GUIUtility.keyboardControl = 0; + ClearPathSuggestions(); Repaint(); } @@ -540,6 +626,7 @@ void CommitPathEdit() { _editingPath = false; GUIUtility.keyboardControl = 0; + ClearPathSuggestions(); var folder = ResolveExternalPath(_pathEditText, out var pingPath); if (folder == null) { @@ -550,6 +637,148 @@ void CommitPathEdit() PingLater(pingPath); } + // ---- path autocomplete --------------------------------------------------- + + void ClearPathSuggestions() + { + _pathSuggestions.Clear(); + _pathSuggestionIndex = -1; + _pathSuggestionQuery = null; + } + + /// Subfolders under the typed prefix; the segment after the last '/' filters them. + void UpdatePathSuggestions() + { + _pathSuggestionQuery = _pathEditText; + _pathSuggestions.Clear(); + _pathSuggestionIndex = -1; + var text = (_pathEditText ?? "").Trim().Trim('"').Replace('\\', '/'); + int slash = text.LastIndexOf('/'); + if (slash < 0) + { + foreach (var root in new[] { ProjectPaths.AssetsRoot, "Packages" }) + if (root.StartsWith(text, StringComparison.OrdinalIgnoreCase) && + !root.Equals(text, StringComparison.OrdinalIgnoreCase)) + _pathSuggestions.Add(root); + return; + } + var parent = text.Substring(0, slash); + var partial = text.Substring(slash + 1); + // "Packages" is not a folder asset itself but GetSubFolders still lists packages. + if (parent != "Packages" && !AssetDatabase.IsValidFolder(parent)) return; + foreach (var sub in AssetDatabase.GetSubFolders(parent)) + { + var path = ProjectPaths.Normalize(sub); + var name = ProjectPaths.GetDisplayName(path); + if (name == null) continue; + if (partial.Length > 0 && !name.StartsWith(partial, StringComparison.OrdinalIgnoreCase)) + continue; + if (path.Equals(text, StringComparison.OrdinalIgnoreCase)) continue; + _pathSuggestions.Add(path); + if (_pathSuggestions.Count >= MaxPathSuggestions) break; + } + } + + /// Tab completion: the suggestion becomes the text, ready for the next segment. + void AcceptSuggestionIntoText(string path) + { + _pathEditText = path + "/"; + MovePathCursorToEnd(); + UpdatePathSuggestions(); + Repaint(); + } + + /// Clicking or Enter on a suggestion navigates there and ends the edit. + void CommitSuggestion(string path) + { + _editingPath = false; + GUIUtility.keyboardControl = 0; + ClearPathSuggestions(); + NavigateTo(path); + } + + /// Completing must not leave the old text selected — put the caret at the end. + void MovePathCursorToEnd() + { + if (GUIUtility.keyboardControl == 0) return; + var editor = (TextEditor)GUIUtility.GetStateObject(typeof(TextEditor), GUIUtility.keyboardControl); + editor.text = _pathEditText; + editor.cursorIndex = editor.selectIndex = _pathEditText.Length; + } + + Rect PathSuggestionBoxRect() + { + return new Rect(_pathFieldRect.x, _pathFieldRect.yMax + 1, _pathFieldRect.width, + _pathSuggestions.Count * SuggestionRowHeight + 2); + } + + Rect PathSuggestionRowRect(int index) + { + var box = PathSuggestionBoxRect(); + return new Rect(box.x + 1, box.y + 1 + index * SuggestionRowHeight, + box.width - 2, SuggestionRowHeight); + } + + /// + /// Mouse interaction with the dropdown. Runs before the embedded browser so the + /// clicks never reach the folder view underneath the overlay. + /// + void HandlePathSuggestionEvents() + { + if (!_editingPath || _pathSuggestions.Count == 0) return; + var e = Event.current; + var box = PathSuggestionBoxRect(); + if (e.type == EventType.MouseMove && box.Contains(e.mousePosition)) + { + _pathSuggestionIndex = Mathf.Clamp( + (int)((e.mousePosition.y - box.y - 1) / SuggestionRowHeight), + 0, _pathSuggestions.Count - 1); + Repaint(); + } + else if (e.type == EventType.MouseDown && box.Contains(e.mousePosition)) + { + int row = Mathf.Clamp((int)((e.mousePosition.y - box.y - 1) / SuggestionRowHeight), + 0, _pathSuggestions.Count - 1); + var path = _pathSuggestions[row]; + e.Use(); + if (e.button == 2) + { + // Middle-click: new tab, like everywhere else in the window. + _editingPath = false; + GUIUtility.keyboardControl = 0; + ClearPathSuggestions(); + OpenInNewTab(path); + } + else + { + CommitSuggestion(path); + } + } + } + + /// Painted at the very end of OnGUI so it overlays the folder view. + void DrawPathSuggestions() + { + if (!_editingPath || _pathSuggestions.Count == 0) return; + if (Event.current.type != EventType.Repaint) return; + var box = PathSuggestionBoxRect(); + var border = EditorGUIUtility.isProSkin ? new Color(0.1f, 0.1f, 0.1f) : new Color(0.4f, 0.4f, 0.4f); + var background = EditorGUIUtility.isProSkin ? new Color(0.2f, 0.2f, 0.2f) : new Color(0.9f, 0.9f, 0.9f); + EditorGUI.DrawRect(box, border); + EditorGUI.DrawRect(new Rect(box.x + 1, box.y + 1, box.width - 2, box.height - 2), background); + for (int i = 0; i < _pathSuggestions.Count; i++) + { + var row = PathSuggestionRowRect(i); + if (i == _pathSuggestionIndex) + EditorGUI.DrawRect(row, new Color(0.24f, 0.49f, 0.91f, 0.5f)); + var icon = AssetDatabase.GetCachedIcon(_pathSuggestions[i]); + if (icon != null) + GUI.DrawTexture(new Rect(row.x + 3, row.y + 1, 16, 16), icon, ScaleMode.ScaleToFit); + GUI.Label(new Rect(row.x + 22, row.y, row.width - 24, row.height), + _pathSuggestions[i], EditorStyles.label); + } + } + // ---- tab bar ----------------------------------------------------------- void DrawTabBar() @@ -587,12 +816,12 @@ void BuildTabTitles() var counts = new Dictionary(); foreach (var tab in _session.Tabs) { - var name = ProjectPaths.GetDisplayName(tab.CurrentPath) ?? "(empty)"; + var name = ProjectPaths.GetDisplayName(tab.CurrentPath) ?? "New Tab"; counts[name] = counts.TryGetValue(name, out var c) ? c + 1 : 1; } foreach (var tab in _session.Tabs) { - var name = ProjectPaths.GetDisplayName(tab.CurrentPath) ?? "(empty)"; + var name = ProjectPaths.GetDisplayName(tab.CurrentPath) ?? "New Tab"; if (counts[name] > 1) { var parent = ProjectPaths.GetDisplayName(ProjectPaths.GetParent(tab.CurrentPath)); @@ -616,7 +845,7 @@ bool DrawTab(int index, int reorderControl) if (tab.Pinned) { // Pinned tabs shrink to their folder icon, like a browser's pinned tabs. - var icon = AssetDatabase.GetCachedIcon(tab.CurrentPath); + var icon = tab.CurrentPath != null ? AssetDatabase.GetCachedIcon(tab.CurrentPath) : null; content = icon != null ? new GUIContent(icon, tab.CurrentPath) : new GUIContent(ProjectPaths.Ellipsize(_tabTitles[index], 4), tab.CurrentPath); @@ -692,12 +921,15 @@ void DrawNewTabButton() OpenInNewTab(null); } - /// Quick Access — bookmarked folders that open as new tabs (right-click the +). + /// + /// Quick Access — bookmarked folders and saved searches that open as new tabs + /// (right-click the +). + /// void ShowQuickAccessMenu(Rect dropRect) { var menu = new GenericMenu(); - if (TabstepBookmarks.Folders.Count == 0) - menu.AddDisabledItem(new GUIContent("No Quick Access folders")); + if (TabstepBookmarks.Folders.Count == 0 && TabstepBookmarks.Searches.Count == 0) + menu.AddDisabledItem(new GUIContent("Quick Access is empty")); foreach (var folder in TabstepBookmarks.Folders) { var path = folder; @@ -706,38 +938,146 @@ void ShowQuickAccessMenu(Rect dropRect) else menu.AddDisabledItem(new GUIContent(MenuPath(path))); } + foreach (var saved in TabstepBookmarks.Searches) + { + var entry = saved; + var label = SavedSearchLabel(entry); + if (AssetDatabase.IsValidFolder(entry.folder)) + menu.AddItem(new GUIContent(label), false, + () => OpenSavedSearchInNewTab(entry.folder, entry.search)); + else + menu.AddDisabledItem(new GUIContent(label)); + } menu.AddSeparator(""); var current = _session.ActiveTab?.CurrentPath; if (current != null && !TabstepBookmarks.Contains(current)) menu.AddItem(new GUIContent("Add Current Folder"), false, () => TabstepBookmarks.Add(current)); else menu.AddDisabledItem(new GUIContent("Add Current Folder")); + var search = _session.ActiveTab?.SearchText; + if (current != null && !string.IsNullOrWhiteSpace(search) && + !TabstepBookmarks.ContainsSearch(current, search)) + menu.AddItem(new GUIContent("Save Current Search"), false, + () => TabstepBookmarks.AddSearch(current, search)); + else + menu.AddDisabledItem(new GUIContent("Save Current Search")); foreach (var folder in TabstepBookmarks.Folders) { var path = folder; menu.AddItem(new GUIContent("Remove/" + MenuPath(path)), false, () => TabstepBookmarks.Remove(path)); } + foreach (var saved in TabstepBookmarks.Searches) + { + var entry = saved; + menu.AddItem(new GUIContent("Remove/" + SavedSearchLabel(entry)), false, + () => TabstepBookmarks.RemoveSearch(entry)); + } menu.DropDown(dropRect); } - /// Every tab as a dropdown — the escape hatch when the bar overflows. + static string SavedSearchLabel(SavedSearch entry) + { + return $"“{entry.search}” in {MenuPath(entry.folder)}"; + } + + /// + /// Every tab as a dropdown — the escape hatch when the bar overflows — plus + /// the workspace menu (named tab sets that can be saved and restored). + /// void DrawTabListButton() { - var content = new GUIContent("▾", "All tabs"); + var content = new GUIContent("▾", "All tabs / workspaces"); var rect = GUILayoutUtility.GetRect(content, EditorStyles.toolbarButton, GUILayout.Width(20)); + // PopupWindow.Show needs a live OnGUI for its screen-space math, so the + // menu item only requests the prompt and it opens on the next pass here. + if (_openWorkspacePopup && Event.current.type == EventType.Repaint) + { + _openWorkspacePopup = false; + PopupWindow.Show(rect, new WorkspaceNamePopup { _owner = this }); + } if (!EditorGUI.DropdownButton(rect, content, FocusType.Passive, EditorStyles.toolbarButton)) return; var menu = new GenericMenu(); for (int i = 0; i < _session.Count; i++) { int index = i; - menu.AddItem(new GUIContent(MenuPath(_session.Tabs[i].CurrentPath)), + menu.AddItem(new GUIContent(MenuPath(_session.Tabs[i].CurrentPath ?? "New Tab")), i == _session.ActiveIndex, () => ActivateTab(index)); } + menu.AddSeparator(""); + foreach (var name in TabstepWorkspaces.Names) + { + var workspaceName = name; + menu.AddItem(new GUIContent("Workspaces/" + workspaceName), false, + () => LoadWorkspace(workspaceName)); + } + if (TabstepWorkspaces.Names.Count > 0) + menu.AddSeparator("Workspaces/"); + menu.AddItem(new GUIContent("Workspaces/Save Tabs As..."), false, () => + { + _openWorkspacePopup = true; + Repaint(); + }); + foreach (var name in TabstepWorkspaces.Names) + { + var workspaceName = name; + menu.AddItem(new GUIContent("Workspaces/Delete/" + workspaceName), false, () => + { + if (EditorUtility.DisplayDialog("Delete Workspace", + $"Delete the workspace \"{workspaceName}\"?", "Delete", "Cancel")) + TabstepWorkspaces.Delete(workspaceName); + }); + } menu.DropDown(rect); } + /// Replaces the current tabs with a stored workspace (after confirming). + void LoadWorkspace(string name) + { + var session = TabstepWorkspaces.Get(name); + if (session == null || session.Count == 0) return; + if (!EditorUtility.DisplayDialog("Load Workspace", + $"Load the workspace \"{name}\"?\nThe current tabs will be replaced.", + "Load", "Cancel")) + return; + _session = session; + _applyTabToBrowser = true; + Repaint(); + } + + internal void SaveWorkspace(string name) + { + TabstepWorkspaces.Save(name, _session); + ShowNotification(new GUIContent($"Workspace \"{name.Trim()}\" saved")); + } + + /// Tiny name prompt for "Save Tabs As..." — Unity has no built-in text dialog. + class WorkspaceNamePopup : PopupWindowContent + { + internal TabstepProjectWindow _owner; + string _name = ""; + + public override Vector2 GetWindowSize() => new Vector2(240, 58); + + public override void OnGUI(Rect rect) + { + EditorGUILayout.LabelField("Save tabs as workspace", EditorStyles.boldLabel); + GUI.SetNextControlName("Tabstep.WorkspaceName"); + _name = EditorGUILayout.TextField(_name); + EditorGUI.FocusTextInControl("Tabstep.WorkspaceName"); + bool submit = Event.current.type == EventType.KeyDown && + (Event.current.keyCode == KeyCode.Return || + Event.current.keyCode == KeyCode.KeypadEnter); + using (new EditorGUI.DisabledScope(string.IsNullOrWhiteSpace(_name))) + if (GUILayout.Button("Save") || (submit && !string.IsNullOrWhiteSpace(_name))) + { + _owner.SaveWorkspace(_name); + editorWindow.Close(); + } + } + } + /// /// Appears at the right of the tab bar only while assets are being dragged: /// dropping parks them on the shelf for a later hand-off instead of moving them. @@ -863,6 +1203,10 @@ void ShowTabContextMenu(int index) () => TabstepShelfWindow.ShowNear(this).AddObjects(Selection.objects)); else menu.AddDisabledItem(new GUIContent("Send Selection to Shelf")); + if (_lastMove.Count > 0) + menu.AddItem(new GUIContent("Undo Last Asset Move"), false, UndoLastMove); + else + menu.AddDisabledItem(new GUIContent("Undo Last Asset Move")); menu.AddSeparator(""); menu.AddItem(new GUIContent("Duplicate Tab"), false, () => { @@ -945,10 +1289,19 @@ static List DraggedProjectPaths() return paths; } - void MoveAssetsTo(string targetFolder, List paths) + /// Active tab's folder, or null on a start page — the shelf hands assets off here. + internal string ActiveFolderPath => _session.ActiveTab?.CurrentPath; + + /// Moves assets into the active tab's folder (used by the shelf's "→ Tab"). + internal int MoveAssetsToActiveFolder(List paths) + { + return MoveAssetsTo(ActiveFolderPath, paths); + } + + int MoveAssetsTo(string targetFolder, List paths) { - if (!AssetDatabase.IsValidFolder(targetFolder)) return; - int moved = 0; + if (string.IsNullOrEmpty(targetFolder) || !AssetDatabase.IsValidFolder(targetFolder)) return 0; + var performed = new List<(string from, string to)>(); foreach (var path in paths) { if (path == targetFolder) continue; @@ -957,11 +1310,35 @@ void MoveAssetsTo(string targetFolder, List paths) var destination = AssetDatabase.GenerateUniqueAssetPath( targetFolder + "/" + ProjectPaths.GetDisplayName(path)); var error = AssetDatabase.MoveAsset(path, destination); - if (string.IsNullOrEmpty(error)) moved++; + if (string.IsNullOrEmpty(error)) performed.Add((path, destination)); else Debug.LogWarning($"[Tabstep] Could not move '{path}': {error}"); } - if (moved > 0) - ShowNotification(new GUIContent($"Moved {moved} asset{(moved == 1 ? "" : "s")} to {targetFolder}")); + if (performed.Count > 0) + { + _lastMove = performed; // context menus offer "Undo Last Asset Move" + ShowNotification(new GUIContent( + $"Moved {performed.Count} asset{(performed.Count == 1 ? "" : "s")} to {targetFolder}")); + Repaint(); + } + return performed.Count; + } + + /// Puts the assets of the last move back where they came from. + void UndoLastMove() + { + int restored = 0; + for (int i = _lastMove.Count - 1; i >= 0; i--) + { + var (from, to) = _lastMove[i]; + var parent = ProjectPaths.GetParent(from); + if (parent == null || !AssetDatabase.IsValidFolder(parent)) continue; + var destination = AssetDatabase.GenerateUniqueAssetPath(from); + if (string.IsNullOrEmpty(AssetDatabase.MoveAsset(to, destination))) restored++; + } + _lastMove.Clear(); + if (restored > 0) + ShowNotification(new GUIContent( + $"Moved {restored} asset{(restored == 1 ? "" : "s")} back")); } /// Dropping folders onto the tab bar opens each of them as a new tab. @@ -999,26 +1376,39 @@ void DrawNavigationBar() bool integrated = ProjectBrowserPatcher.Active; EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); - // Back/forward get explicit rects: right-clicking either lists the history, - // and that must work even while the button itself is disabled. - var backContent = new GUIContent("◀", "Back (Alt+Left)\nRight-click: history"); + // Back/forward/up get explicit rects: right-clicking lists the history, + // middle-clicking opens the target in a new tab, and hovering with a drag + // in flight spring-loads the navigation — all even while the button itself + // is disabled or busy. + var backContent = new GUIContent("◀", "Back (Alt+Left)\nRight-click: history\nMiddle-click: open in new tab"); var backRect = GUILayoutUtility.GetRect(backContent, EditorStyles.toolbarButton, GUILayout.Width(26)); HandleHistoryMenuClick(backRect, tab); + HandleNavMiddleClick(backRect, tab != null && tab.CanGoBack + ? tab.History[tab.HistoryIndex - 1] : null); + HandleNavSpringLoad(backRect, 1, tab != null && tab.CanGoBack, GoBack); using (new EditorGUI.DisabledScope(tab == null || !tab.CanGoBack)) if (GUI.Button(backRect, backContent, EditorStyles.toolbarButton)) GoBack(); - var forwardContent = new GUIContent("▶", "Forward (Alt+Right)\nRight-click: history"); + var forwardContent = new GUIContent("▶", "Forward (Alt+Right)\nRight-click: history\nMiddle-click: open in new tab"); var forwardRect = GUILayoutUtility.GetRect(forwardContent, EditorStyles.toolbarButton, GUILayout.Width(26)); HandleHistoryMenuClick(forwardRect, tab); + HandleNavMiddleClick(forwardRect, tab != null && tab.CanGoForward + ? tab.History[tab.HistoryIndex + 1] : null); + HandleNavSpringLoad(forwardRect, 2, tab != null && tab.CanGoForward, GoForward); using (new EditorGUI.DisabledScope(tab == null || !tab.CanGoForward)) if (GUI.Button(forwardRect, forwardContent, EditorStyles.toolbarButton)) GoForward(); var parent = ProjectPaths.GetParent(tab?.CurrentPath); - using (new EditorGUI.DisabledScope(parent == null || !AssetDatabase.IsValidFolder(parent))) - if (GUILayout.Button(new GUIContent("▲", "Parent folder (Alt+Up)"), EditorStyles.toolbarButton, - GUILayout.Width(26))) + bool canGoUp = parent != null && AssetDatabase.IsValidFolder(parent); + var upContent = new GUIContent("▲", "Parent folder (Alt+Up)\nMiddle-click: open in new tab"); + var upRect = GUILayoutUtility.GetRect(upContent, EditorStyles.toolbarButton, + GUILayout.Width(26)); + HandleNavMiddleClick(upRect, canGoUp ? parent : null); + HandleNavSpringLoad(upRect, 3, canGoUp, GoUp); + using (new EditorGUI.DisabledScope(!canGoUp)) + if (GUI.Button(upRect, upContent, EditorStyles.toolbarButton)) GoUp(); if (integrated) DrawCreateButton(); @@ -1032,6 +1422,7 @@ void DrawNavigationBar() if (integrated) { DrawSearchField(); + DrawSearchChips(); GUILayout.Space(2); } @@ -1053,6 +1444,98 @@ void HandleHistoryMenuClick(Rect rect, TabState tab) ShowHistoryMenu(rect, tab); } + /// Middle-clicking a nav button opens its destination in a new tab. + void HandleNavMiddleClick(Rect rect, string destination) + { + var e = Event.current; + if (e.type != EventType.MouseDown || e.button != 2 || !rect.Contains(e.mousePosition)) + return; + e.Use(); + if (destination != null && AssetDatabase.IsValidFolder(destination)) + OpenInNewTab(destination); + } + + /// + /// Hovering a nav button with a drag in flight navigates after a moment (and + /// keeps navigating step by step), like Explorer — so a drag started deep in + /// one folder can walk back through the history to its target. + /// + void HandleNavSpringLoad(Rect rect, int id, bool canNavigate, Action navigate) + { + var e = Event.current; + if (e.type != EventType.DragUpdated) + { + if (_navSpringTarget == id && e.type == EventType.DragExited) + _navSpringTarget = 0; + return; + } + if (!rect.Contains(e.mousePosition)) + { + if (_navSpringTarget == id) _navSpringTarget = 0; + return; + } + if (!canNavigate) return; + DragAndDrop.visualMode = DragAndDropVisualMode.Move; + if (_navSpringTarget != id) + { + _navSpringTarget = id; + _navSpringStart = EditorApplication.timeSinceStartup; + } + else if (EditorApplication.timeSinceStartup - _navSpringStart > SpringLoadDelay) + { + navigate(); + _navSpringStart = EditorApplication.timeSinceStartup; // step again after another delay + } + Repaint(); // keep the timer ticking + e.Use(); + } + + /// One-click t: filters next to the search field (configurable in preferences). + void DrawSearchChips() + { + var chips = TabstepSettings.SearchChips; + if (string.IsNullOrWhiteSpace(chips)) return; + foreach (var raw in chips.Split(',')) + { + var chip = raw.Trim(); + if (chip.Length == 0) continue; + string token = "t:" + chip; + bool on = HasSearchToken(_searchText, token); + bool now = GUILayout.Toggle(on, new GUIContent(chip, "Toggle the " + token + " filter"), + EditorStyles.toolbarButton, GUILayout.ExpandWidth(false)); + if (now == on) continue; + var text = ToggleSearchToken(_host.GetSearchText() ?? _searchText, token); + _searchText = text; + _host.SetSearch(text); + _lastAppliedSearch = _host.GetSearchText() ?? text; + if (_session.ActiveTab != null) + _session.ActiveTab.SearchText = text; + } + } + + /// Whitespace-token containment, case-insensitive ("t:Prefab" in "boss t:Prefab"). + internal static bool HasSearchToken(string text, string token) + { + if (string.IsNullOrEmpty(text)) return false; + foreach (var part in text.Split(' ')) + if (part.Equals(token, StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + + /// Adds the token to the search text, or removes it when already present. + internal static string ToggleSearchToken(string text, string token) + { + text ??= ""; + if (!HasSearchToken(text, token)) + return (text + " " + token).Trim(); + var parts = new List(); + foreach (var part in text.Split(' ')) + if (part.Length > 0 && !part.Equals(token, StringComparison.OrdinalIgnoreCase)) + parts.Add(part); + return string.Join(" ", parts); + } + /// Right-clicking back/forward lists the whole history, newest first. void ShowHistoryMenu(Rect dropRect, TabState tab) { @@ -1165,6 +1648,20 @@ void DrawAddressBar(Rect rect, TabState tab) var inner = new Rect(rect.x + 4, rect.y, rect.width - 8, rect.height); float x = inner.x; + if (tab.CurrentPath == null) + { + // Start page tab — no crumbs yet; the bar invites typing a path. + GUI.Label(inner, "New Tab — type a path (Ctrl+L) or pick a folder below", + EditorStyles.centeredGreyMiniLabel); + EditorGUIUtility.AddCursorRect(inner, MouseCursor.Text); + if (e.type == EventType.MouseDown && e.button == 0 && inner.Contains(e.mousePosition)) + { + e.Use(); + BeginPathEdit(); + } + return; + } + // Folder icon at the left, like Explorer's address bar. var icon = AssetDatabase.GetCachedIcon(tab.CurrentPath); if (icon != null) @@ -1292,28 +1789,63 @@ float DrawCrumb((string name, string path) crumb, bool isCurrent, float x, Rect if (isCurrent) BeginPathEdit(); else if (AssetDatabase.IsValidFolder(crumb.path)) NavigateTo(crumb.path); } + else if (e.type == EventType.MouseDown && e.button == 2 && hover) + { + // Middle-click opens the segment in a new tab, like a browser link. + e.Use(); + if (AssetDatabase.IsValidFolder(crumb.path)) OpenInNewTab(crumb.path); + } return rect.xMax; } void DrawPathField(Rect rect) { + _pathFieldRect = rect; // anchors the autocomplete dropdown var e = Event.current; if (e.type == EventType.KeyDown && GUI.GetNameOfFocusedControl() == PathFieldControl) { if (e.keyCode == KeyCode.Return || e.keyCode == KeyCode.KeypadEnter) { e.Use(); - CommitPathEdit(); + // Enter on a highlighted suggestion takes the suggestion; otherwise + // the typed text is committed as-is. + if (_pathSuggestionIndex >= 0 && _pathSuggestionIndex < _pathSuggestions.Count) + CommitSuggestion(_pathSuggestions[_pathSuggestionIndex]); + else + CommitPathEdit(); } else if (e.keyCode == KeyCode.Escape) { e.Use(); CancelPathEdit(); } + else if (e.keyCode == KeyCode.Tab && _pathSuggestions.Count > 0) + { + // Tab completes (IMGUI would otherwise move keyboard focus). + e.Use(); + int pick = _pathSuggestionIndex >= 0 ? _pathSuggestionIndex : 0; + AcceptSuggestionIntoText(_pathSuggestions[pick]); + } + else if (e.keyCode == KeyCode.DownArrow && _pathSuggestions.Count > 0) + { + e.Use(); + _pathSuggestionIndex = (_pathSuggestionIndex + 1) % _pathSuggestions.Count; + Repaint(); + } + else if (e.keyCode == KeyCode.UpArrow && _pathSuggestions.Count > 0) + { + e.Use(); + _pathSuggestionIndex = _pathSuggestionIndex <= 0 + ? _pathSuggestions.Count - 1 + : _pathSuggestionIndex - 1; + Repaint(); + } } GUI.SetNextControlName(PathFieldControl); _pathEditText = GUI.TextField(rect, _pathEditText, EditorStyles.textField); + if (_pathEditText != _pathSuggestionQuery) + UpdatePathSuggestions(); if (_focusPathField) { @@ -1326,7 +1858,200 @@ void DrawPathField(Rect rect) // Focus moved elsewhere (clicked into the browser...) — revert to // breadcrumbs without navigating, like Explorer. _editingPath = false; + ClearPathSuggestions(); + } + } + + // ---- start page ---------------------------------------------------------- + + /// + /// What an empty tab shows instead of the embedded browser: Quick Access, + /// saved searches and recently closed tabs. Clicking navigates this tab; + /// middle-clicking opens a new one. + /// + void DrawStartPage(Rect content) + { + GUILayout.BeginArea(content); + _startPageScroll = EditorGUILayout.BeginScrollView(_startPageScroll); + EditorGUILayout.Space(10); + + GUILayout.Label("Quick Access", EditorStyles.boldLabel); + if (TabstepBookmarks.Folders.Count == 0) + GUILayout.Label("Right-click a tab (or the + button) to add folders here.", + EditorStyles.miniLabel); + else + DrawStartFolderButtons(TabstepBookmarks.Folders); + + if (TabstepBookmarks.Searches.Count > 0) + { + EditorGUILayout.Space(10); + GUILayout.Label("Saved Searches", EditorStyles.boldLabel); + foreach (var saved in TabstepBookmarks.Searches) + { + var entry = saved; + bool valid = AssetDatabase.IsValidFolder(entry.folder); + var label = new GUIContent($"“{entry.search}” — {entry.folder}", entry.folder); + using (new EditorGUI.DisabledScope(!valid)) + if (GUILayout.Button(label, GUILayout.Height(22))) + { + if (Event.current.button == 2) + { + OpenSavedSearchInNewTab(entry.folder, entry.search); + } + else + { + var tab = _session.ActiveTab; + tab.Navigate(entry.folder); + tab.SearchText = entry.search; + _applyTabToBrowser = true; + Repaint(); + } + } + } + } + + DrawStartRecentlyClosed(); + + EditorGUILayout.EndScrollView(); + GUILayout.EndArea(); + } + + void DrawStartFolderButtons(IReadOnlyList folders) + { + const float buttonWidth = 180f; + int perRow = Mathf.Max(1, (int)((position.width - 20) / (buttonWidth + 6))); + int i = 0; + while (i < folders.Count) + { + EditorGUILayout.BeginHorizontal(); + for (int column = 0; column < perRow && i < folders.Count; column++, i++) + DrawStartFolderButton(folders[i], buttonWidth); + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + } + } + + void DrawStartFolderButton(string path, float width) + { + bool valid = AssetDatabase.IsValidFolder(path); + var icon = valid ? AssetDatabase.GetCachedIcon(path) : null; + var content = new GUIContent(" " + (ProjectPaths.GetDisplayName(path) ?? path), icon, path); + using (new EditorGUI.DisabledScope(!valid)) + if (GUILayout.Button(content, GUILayout.Width(width), GUILayout.Height(24))) + { + if (Event.current.button == 2) OpenInNewTab(path); + else NavigateTo(path); + } + } + + void DrawStartRecentlyClosed() + { + var closed = _session.RecentlyClosed; + bool any = false; + int shown = 0; + for (int i = closed.Count - 1; i >= 0 && shown < 5; i--) + { + var path = closed[i].CurrentPath; + if (path == null) continue; // closed start pages are not worth listing + if (!any) + { + EditorGUILayout.Space(10); + GUILayout.Label("Recently Closed", EditorStyles.boldLabel); + any = true; + } + shown++; + bool valid = AssetDatabase.IsValidFolder(path); + var icon = valid ? AssetDatabase.GetCachedIcon(path) : null; + using (new EditorGUI.DisabledScope(!valid)) + if (GUILayout.Button(new GUIContent(" " + path, icon), GUILayout.Height(22))) + { + if (Event.current.button == 2) OpenInNewTab(path); + else NavigateTo(path); + } + } + } + + // ---- status bar ---------------------------------------------------------- + + /// Bottom row: item count of the shown folder, and the selection summary. + void DrawStatusBar(Rect rect) + { + if (Event.current.type == EventType.Repaint) + EditorStyles.toolbar.Draw(rect, GUIContent.none, false, false, false, false); + var tab = _session.ActiveTab; + string left = tab?.CurrentPath == null + ? "New Tab" + : $"{FolderItemCount(tab.CurrentPath)} items"; + GUI.Label(new Rect(rect.x + 6, rect.y, rect.width / 2, rect.height), + left, EditorStyles.miniLabel); + var right = SelectionSummary(); + if (right.Length > 0) + GUI.Label(new Rect(rect.x + rect.width / 2, rect.y, rect.width / 2 - 6, rect.height), + right, RightMiniLabel); + } + + /// Direct (non-recursive) children, .meta files excluded; cached for 2 seconds. + int FolderItemCount(string folder) + { + if (folder == _statusFolder && + EditorApplication.timeSinceStartup - _statusFolderTime < 2) + return _statusItemCount; + _statusFolder = folder; + _statusFolderTime = EditorApplication.timeSinceStartup; + _statusItemCount = 0; + try + { + var physical = Path.GetFullPath(FileUtil.GetPhysicalPath(folder)); + if (Directory.Exists(physical)) + foreach (var entry in Directory.EnumerateFileSystemEntries(physical)) + if (!entry.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) + _statusItemCount++; + } + catch + { + // Inaccessible folder (immutable package on a network drive...) — show 0. + } + return _statusItemCount; + } + + /// "3 selected • 1.2 MB" for the selected assets; size capped at 100 files. + string SelectionSummary() + { + if (EditorApplication.timeSinceStartup - _statusSelectionTime < 0.5) + return _statusSelectionText; + _statusSelectionTime = EditorApplication.timeSinceStartup; + var guids = Selection.assetGUIDs; + if (guids.Length == 0) return _statusSelectionText = ""; + long bytes = 0; + int files = 0; + int limit = Math.Min(guids.Length, 100); + for (int i = 0; i < limit; i++) + { + try + { + var path = AssetDatabase.GUIDToAssetPath(guids[i]); + var physical = Path.GetFullPath(FileUtil.GetPhysicalPath(path)); + if (File.Exists(physical)) + { + bytes += new FileInfo(physical).Length; + files++; + } + } + catch + { + // Skip whatever cannot be sized. + } } + var text = guids.Length + " selected"; + if (files > 0) + text += " • " + EditorUtility.FormatBytes(bytes) + (guids.Length > limit ? "+" : ""); + return _statusSelectionText = text; + } + + void OnSelectionChange() + { + _statusSelectionTime = 0; // recompute on the next repaint + Repaint(); } void ShowPathBarContextMenu() @@ -1334,15 +2059,27 @@ void ShowPathBarContextMenu() var tab = _session.ActiveTab; if (tab == null) return; var menu = new GenericMenu(); - menu.AddItem(new GUIContent("Copy Path"), false, - () => EditorGUIUtility.systemCopyBuffer = tab.CurrentPath); - menu.AddItem(new GUIContent("Copy Absolute Path"), false, - () => EditorGUIUtility.systemCopyBuffer = ToAbsolutePath(tab.CurrentPath)); + if (tab.CurrentPath != null) + { + menu.AddItem(new GUIContent("Copy Path"), false, + () => EditorGUIUtility.systemCopyBuffer = tab.CurrentPath); + menu.AddItem(new GUIContent("Copy Absolute Path"), false, + () => EditorGUIUtility.systemCopyBuffer = ToAbsolutePath(tab.CurrentPath)); + } + else + { + menu.AddDisabledItem(new GUIContent("Copy Path")); + menu.AddDisabledItem(new GUIContent("Copy Absolute Path")); + } if (ResolveExternalPath(EditorGUIUtility.systemCopyBuffer, out _) != null) menu.AddItem(new GUIContent("Paste Path"), false, PastePathIntoActiveTab); else menu.AddDisabledItem(new GUIContent("Paste Path")); menu.AddSeparator(""); + if (_lastMove.Count > 0) + menu.AddItem(new GUIContent("Undo Last Asset Move"), false, UndoLastMove); + else + menu.AddDisabledItem(new GUIContent("Undo Last Asset Move")); menu.AddItem(new GUIContent("Edit Path"), false, BeginPathEdit); menu.ShowAsContext(); } diff --git a/Editor/Window/TabstepShelfWindow.cs b/Editor/Window/TabstepShelfWindow.cs index b50aa6d..f06a9f2 100644 --- a/Editor/Window/TabstepShelfWindow.cs +++ b/Editor/Window/TabstepShelfWindow.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using UnityEditor; +using UnityEditorInternal; using UnityEngine; using Object = UnityEngine.Object; @@ -22,6 +23,7 @@ namespace Yozolab.Tabstep class TabstepShelfWindow : EditorWindow { const string DragKey = "Tabstep.ShelfDrag"; + const string AllKey = "*"; // generic-data value for the drag-all handle const string AliveKey = "Tabstep.ShelfAlive"; const float RowHeight = 22f; static readonly Vector2 DefaultSize = new Vector2(230, 190); @@ -33,9 +35,11 @@ class TabstepShelfWindow : EditorWindow Vector2 _scroll; ShelfItem _mouseDownItem; - // One-shot bookkeeping: the item consumed by the current drag-out, restored + bool _dragAllArmed; + // One-shot bookkeeping: the item(s) consumed by the current drag-out, restored // if the very same drag re-enters the shelf and drops. - ShelfItem _lastDraggedOut; + readonly List _draggedOut = new List(); + string _draggedOutKey; public static TabstepShelfWindow Instance { @@ -143,7 +147,9 @@ void DrawHeader() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); GUILayout.Label("Shelf", EditorStyles.boldLabel, GUILayout.ExpandWidth(false)); + DrawDragAllHandle(); GUILayout.FlexibleSpace(); + DrawMoveToTabButton(); _keepOpen = GUILayout.Toggle(_keepOpen, new GUIContent("Pin", "Keep the shelf open when the Tabstep window closes"), EditorStyles.toolbarButton, GUILayout.ExpandWidth(false)); @@ -162,7 +168,8 @@ void DrawHeader() var headerRect = GUILayoutUtility.GetLastRect(); var e = Event.current; // Dragging the empty part of the header moves the popup window. - if (e.type == EventType.MouseDrag && e.button == 0 && _mouseDownItem == null && + if (e.type == EventType.MouseDrag && e.button == 0 && + _mouseDownItem == null && !_dragAllArmed && headerRect.Contains(e.mousePosition - e.delta)) { position = new Rect(position.position + e.delta, position.size); @@ -170,6 +177,76 @@ void DrawHeader() } } + /// Drag this handle to carry every (living) item out in one drag. + void DrawDragAllHandle() + { + if (_items.Count == 0) return; + var content = new GUIContent("≡", "Drag all items out at once"); + var rect = GUILayoutUtility.GetRect(content, EditorStyles.toolbarButton, GUILayout.Width(24)); + GUI.Label(rect, content, EditorStyles.toolbarButton); + var e = Event.current; + switch (e.type) + { + case EventType.MouseDown when e.button == 0 && rect.Contains(e.mousePosition): + _dragAllArmed = true; + e.Use(); + break; + case EventType.MouseUp: + _dragAllArmed = false; + break; + case EventType.MouseDrag when _dragAllArmed: + _dragAllArmed = false; + StartDragAll(); + e.Use(); + break; + } + } + + void StartDragAll() + { + var objects = new List(); + var paths = new List(); + foreach (var item in _items) + { + var obj = item.Resolve(); + if (obj == null) continue; + objects.Add(obj); + if (item.AssetPath != null) paths.Add(item.AssetPath); + } + if (objects.Count == 0) return; + _draggedOut.Clear(); + _draggedOutKey = null; + DragAndDrop.PrepareStartDrag(); + DragAndDrop.objectReferences = objects.ToArray(); + if (paths.Count > 0) DragAndDrop.paths = paths.ToArray(); + DragAndDrop.SetGenericData(DragKey, AllKey); + DragAndDrop.StartDrag($"Shelf ({objects.Count} items)"); + } + + /// Hands every asset item over to the active Tabstep tab's folder. + void DrawMoveToTabButton() + { + var windows = Resources.FindObjectsOfTypeAll(); + var target = windows.Length > 0 ? windows[0] : null; + var assetPaths = new List(); + foreach (var item in _items) + if (item.AssetPath != null && item.Resolve() != null) + assetPaths.Add(item.AssetPath); + using (new EditorGUI.DisabledScope(target == null || target.ActiveFolderPath == null || + assetPaths.Count == 0)) + { + if (!GUILayout.Button(new GUIContent("→ Tab", + "Move all asset items into the active tab's folder"), + EditorStyles.toolbarButton, GUILayout.ExpandWidth(false))) + return; + var folder = target.ActiveFolderPath; + if (target.MoveAssetsToActiveFolder(assetPaths) > 0) + // Hand-off complete: drop everything that now lives in that folder. + _items.RemoveAll(item => item.AssetPath != null && + ProjectPaths.GetParent(item.AssetPath) == folder); + } + } + /// Popup windows have no frame; a 1px outline keeps the shelf readable. void DrawBorder() { @@ -247,12 +324,17 @@ bool DrawItem(ShelfItem item) _mouseDownItem = item; e.Use(); break; + case EventType.MouseDown when e.button == 1 && rect.Contains(e.mousePosition): + e.Use(); + ShowItemContextMenu(item, obj); + break; case EventType.MouseUp: _mouseDownItem = null; break; case EventType.MouseDrag when _mouseDownItem == item && alive: _mouseDownItem = null; - _lastDraggedOut = null; + _draggedOut.Clear(); + _draggedOutKey = null; DragAndDrop.PrepareStartDrag(); DragAndDrop.objectReferences = new[] { obj }; var path = item.AssetPath; @@ -265,6 +347,51 @@ bool DrawItem(ShelfItem item) return true; } + /// + /// Per-item actions. For components this is the relay the drag can't do: + /// pasting a copy onto the selected GameObjects. + /// + void ShowItemContextMenu(ShelfItem item, Object obj) + { + var menu = new GenericMenu(); + if (obj != null) + { + menu.AddItem(new GUIContent("Ping"), false, () => EditorGUIUtility.PingObject(obj)); + menu.AddItem(new GUIContent("Select"), false, () => Selection.activeObject = obj); + } + else + { + menu.AddDisabledItem(new GUIContent("Ping")); + menu.AddDisabledItem(new GUIContent("Select")); + } + if (obj is Component component) + { + menu.AddSeparator(""); + int targets = Selection.gameObjects.Length; + if (targets > 0) + menu.AddItem(new GUIContent($"Add To Selected GameObject{(targets == 1 ? "" : "s")}"), + false, () => + { + foreach (var go in Selection.gameObjects) + { + ComponentUtility.CopyComponent(component); + ComponentUtility.PasteComponentAsNew(go); + } + }); + else + menu.AddDisabledItem(new GUIContent("Add To Selected GameObjects")); + menu.AddItem(new GUIContent("Copy Component"), false, + () => ComponentUtility.CopyComponent(component)); + } + menu.AddSeparator(""); + menu.AddItem(new GUIContent("Remove"), false, () => + { + _items.Remove(item); + Repaint(); + }); + menu.ShowAsContext(); + } + // ---- drag & drop in/out -------------------------------------------------- void HandleDragEvents() @@ -276,15 +403,16 @@ void HandleDragEvents() case EventType.DragPerform: { var key = DragAndDrop.GetGenericData(DragKey) as string; - if (key != null && _lastDraggedOut != null && _lastDraggedOut.Key == key) + if (key != null && key == _draggedOutKey && _draggedOut.Count > 0) { - // The one-shot drag came back: let it drop to restore the item. + // The one-shot drag came back: let it drop to restore the item(s). DragAndDrop.visualMode = DragAndDropVisualMode.Move; if (e.type == EventType.DragPerform) { DragAndDrop.AcceptDrag(); - _items.Add(_lastDraggedOut); - _lastDraggedOut = null; + _items.AddRange(_draggedOut); + _draggedOut.Clear(); + _draggedOutKey = null; e.Use(); // The row count changed mid-pass; abort before the stale layout draws. GUIUtility.ExitGUI(); @@ -314,23 +442,28 @@ void HandleDragEvents() } case EventType.DragExited: // Our drag left the shelf (heading for its target). With One-Shot on, - // that consumes the item — it returns only if the drag re-enters. + // that consumes the item(s) — they return only if the drag re-enters. // A drag released inside the shelf (a cancelled pick-up) is not an exit. if (!new Rect(0, 0, position.width, position.height).Contains(e.mousePosition)) - ConsumeDraggedOutItem(); + ConsumeDraggedOutItems(); break; } } - void ConsumeDraggedOutItem() + void ConsumeDraggedOutItems() { if (!TabstepSettings.ShelfOneShot) return; var key = DragAndDrop.GetGenericData(DragKey) as string; if (key == null) return; - var item = _items.Find(existing => existing.Key == key); - if (item == null) return; - _items.Remove(item); - _lastDraggedOut = item; + var taken = key == AllKey + ? new List(_items) + : _items.FindAll(existing => existing.Key == key); + if (taken.Count == 0) return; + foreach (var item in taken) + _items.Remove(item); + _draggedOut.Clear(); + _draggedOut.AddRange(taken); + _draggedOutKey = key; Repaint(); // An emptied unpinned shelf folds away once the drag has finished. if (_items.Count == 0 && !_keepOpen) diff --git a/README.md b/README.md index 6b33795..be171f4 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,27 @@ like the built-in Project window. On top of it, Tabstep adds: the last closed tab with its full history; the last 10 are remembered. - **Back / forward history per tab** — navigate like a web browser, including with the mouse side (thumb) buttons. Right-click ◀ / ▶ for the full history - as a dropdown. + as a dropdown; middle-click ◀ / ▶ / ▲ (or a breadcrumb segment) to open the + destination in a new tab. Hovering ◀ / ▶ / ▲ while dragging assets + spring-loads the navigation, so a drag can walk through the history. - **Per-tab search** — each tab remembers its own search filter and restores - it when the tab becomes active again. -- **Quick Access** — right-click the `+` button for bookmarked folders; add - the current folder from there or from a tab's context menu. Stored per user - and per project in `UserSettings/`. + it when the tab becomes active again. Configurable filter chips next to the + search field toggle `t:` filters with one click. +- **Path autocomplete** — while editing the path (`Ctrl+L`), subfolder + suggestions drop down under the bar: `Tab` completes, `↑`/`↓` select, + `Enter` goes, clicking a suggestion jumps (middle-click: new tab). +- **Quick Access** — right-click the `+` button for bookmarked folders *and + saved searches* (a filter pinned to the folder it was made in); add either + from that menu or from a tab's context menu. Stored per user and per + project in `UserSettings/`. +- **Start page** — a fresh tab (`Ctrl+T`) shows Quick Access, saved searches + and recently closed tabs instead of jumping to a folder (toggleable). +- **Workspaces** — save the whole tab set (order, pins, histories, searches) + under a name via the `▾` dropdown and restore it any time. +- **Undo asset moves** — moved something onto the wrong tab? *Undo Last Asset + Move* in the tab / path-bar context menus puts everything back. +- **Status bar** — item count of the shown folder plus the selection's count + and file size at the bottom (toggleable). - **Explorer-style address bar** — back/forward/up buttons, the folder icon and the current path as clickable breadcrumb segments in a sunken field (hover highlights them; parents that don't fit collapse behind `«`). Click any @@ -69,6 +84,11 @@ another tab's folder, an Inspector object field, the Hierarchy, the Scene view. - **One-shot by default** — dragging an item out consumes it: the shelf is a hand-off, not a copy source. Dragging it back before dropping restores it; the behaviour is toggleable in preferences. +- **Bulk hand-offs** — the `≡` handle drags every item out in one go, and + `→ Tab` moves all asset items straight into the active tab's folder. +- **Component relay** — drop a component (drag its Inspector header) on the + shelf, then right-click it to paste a copy onto the selected GameObjects. + Right-click also offers ping / select / copy for every item. - **Stays out of the way** — shown as a borderless popup that never steals focus, so you can switch tabs underneath it mid-drag. Drag its title row to move it. It survives domain reloads, closes with its Tabstep window (unless @@ -99,7 +119,7 @@ Open via `YozoLab > Tabstep`. | `Alt+←` / `Alt+→` | Back / forward | | Mouse side buttons | Back / forward | | `Alt+↑` | Parent folder | -| `Ctrl+L` / `Alt+D` | Edit the path (`Enter` to go, `Esc` to cancel) | +| `Ctrl+L` / `Alt+D` | Edit the path (`Tab` completes, `↑`/`↓` select, `Enter` goes, `Esc` cancels) | | `Ctrl+F` | Focus the search field (when it lives in the navigation bar) | | `Ctrl+Shift+C` | Copy the current folder's absolute path | | `Ctrl+Shift+D` | Toggle the shelf | @@ -111,8 +131,9 @@ Open via `YozoLab > Tabstep`. | Right-click path bar | Copy path / copy absolute path / paste path / edit path | Preferences live under `Edit > Preferences > Yozolab > Tabstep` -(new-tab folder, new-tab position, navigation bar, tab title length, -middle-click close, mouse side buttons, ping behaviour, shelf one-shot). +(new-tab folder, new-tab position, start page, navigation bar, status bar, +search filter chips, tab title length, middle-click close, mouse side +buttons, ping behaviour, shelf one-shot). ## Harmony (optional but recommended) diff --git a/Tests/SearchTokenTests.cs b/Tests/SearchTokenTests.cs new file mode 100644 index 0000000..42913e7 --- /dev/null +++ b/Tests/SearchTokenTests.cs @@ -0,0 +1,46 @@ +using NUnit.Framework; + +namespace Yozolab.Tabstep.Tests +{ + public class SearchTokenTests + { + [Test] + public void HasSearchToken_MatchesWholeTokensOnly() + { + Assert.IsTrue(TabstepProjectWindow.HasSearchToken("boss t:Prefab", "t:Prefab")); + Assert.IsTrue(TabstepProjectWindow.HasSearchToken("T:PREFAB", "t:Prefab")); + Assert.IsFalse(TabstepProjectWindow.HasSearchToken("t:PrefabVariant", "t:Prefab")); + Assert.IsFalse(TabstepProjectWindow.HasSearchToken("", "t:Prefab")); + Assert.IsFalse(TabstepProjectWindow.HasSearchToken(null, "t:Prefab")); + } + + [Test] + public void ToggleSearchToken_AppendsWhenMissing() + { + Assert.AreEqual("boss t:Prefab", + TabstepProjectWindow.ToggleSearchToken("boss", "t:Prefab")); + Assert.AreEqual("t:Prefab", + TabstepProjectWindow.ToggleSearchToken(null, "t:Prefab")); + Assert.AreEqual("t:Prefab", + TabstepProjectWindow.ToggleSearchToken("", "t:Prefab")); + } + + [Test] + public void ToggleSearchToken_RemovesWhenPresent_CaseInsensitive() + { + Assert.AreEqual("boss", + TabstepProjectWindow.ToggleSearchToken("boss t:Prefab", "t:Prefab")); + Assert.AreEqual("boss", + TabstepProjectWindow.ToggleSearchToken("T:PREFAB boss", "t:Prefab")); + Assert.AreEqual("", + TabstepProjectWindow.ToggleSearchToken("t:Prefab", "t:Prefab")); + } + + [Test] + public void ToggleSearchToken_LeavesOtherTokensAlone() + { + Assert.AreEqual("boss l:Enemy", + TabstepProjectWindow.ToggleSearchToken("boss t:Prefab l:Enemy", "t:Prefab")); + } + } +} diff --git a/Tests/SearchTokenTests.cs.meta b/Tests/SearchTokenTests.cs.meta new file mode 100644 index 0000000..c48bca8 --- /dev/null +++ b/Tests/SearchTokenTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 48d9eb88841f477e8e53d9a282105371 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/TabSessionTests.cs b/Tests/TabSessionTests.cs index 05e10d8..45d34cd 100644 --- a/Tests/TabSessionTests.cs +++ b/Tests/TabSessionTests.cs @@ -256,6 +256,38 @@ public void OpenTabAfterActive_InsertsNextToTheActiveTab() TabPaths(session)); } + [Test] + public void AddTab_BesideActivePinnedTab_LandsOutsideThePinnedRegion() + { + var session = SessionWith("Assets", "Assets/A"); + session.SetPinned(1, true); // Assets/A pinned, moved to index 0, stays active + session.Activate(0); + session.AddTab(new TabState("Assets/C"), besideActive: true); + CollectionAssert.AreEqual(new[] { "Assets/A", "Assets/C", "Assets" }, + TabPaths(session)); + Assert.IsFalse(session.Tabs[1].Pinned); + } + + [Test] + public void AddTab_StartPageTab_HasNoPathUntilNavigated() + { + var session = SessionWith("Assets"); + var tab = session.AddTab(new TabState(), besideActive: false); + Assert.IsNull(tab.CurrentPath); + tab.Navigate("Assets/A"); + Assert.AreEqual("Assets/A", tab.CurrentPath); + Assert.IsFalse(tab.CanGoBack); + } + + [Test] + public void RecentlyClosed_PeeksMostRecentLast() + { + var session = SessionWith("Assets", "Assets/A"); + session.CloseTab(1); + Assert.AreEqual(1, session.RecentlyClosed.Count); + Assert.AreEqual("Assets/A", session.RecentlyClosed[0].CurrentPath); + } + static string[] TabPaths(TabSession session) { var paths = new string[session.Count]; From ad2d1fd24b29d1af548508b0b571624d17c97d7f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:18:00 +0000 Subject: [PATCH 3/8] fix: shelf vanishing on open, middle-click in the asset list, drop start page - Shelf: the ghost-detection flag was raised only after CreateInstance, but OnEnable (which runs inside it) reads the flag - every fresh shelf therefore looked like a layout ghost and closed itself immediately. Raise the flag first. - Middle-click on a folder in the embedded asset list now opens it in a new tab: the press is converted to a left click so the browser selects the item, and the release reads the selection back. Restricted to the list area so folder-tree clicks stay untouched. - Removed the start page: a fresh tab opens the default folder (Assets) again. Quick Access and saved searches remain available from the + button menu. --- Editor/TabstepSettings.cs | 14 +- Editor/Window/ProjectBrowserHost.cs | 18 +++ Editor/Window/TabstepProjectWindow.cs | 215 +++++++------------------- Editor/Window/TabstepShelfWindow.cs | 5 +- README.md | 15 +- Tests/TabSessionTests.cs | 2 +- 6 files changed, 84 insertions(+), 185 deletions(-) diff --git a/Editor/TabstepSettings.cs b/Editor/TabstepSettings.cs index 85f6220..27bed9d 100644 --- a/Editor/TabstepSettings.cs +++ b/Editor/TabstepSettings.cs @@ -29,13 +29,6 @@ public static bool NewTabBesideActive set => EditorPrefs.SetBool(Prefix + "NewTabBesideActive", value); } - /// A fresh tab (Ctrl+T) shows the start page instead of jumping to a folder. - public static bool NewTabStartPage - { - get => EditorPrefs.GetBool(Prefix + "NewTabStartPage", true); - set => EditorPrefs.SetBool(Prefix + "NewTabStartPage", value); - } - /// Status bar at the bottom: folder item count and selection summary. public static bool ShowStatusBar { @@ -96,7 +89,7 @@ public static void ResetAll() { "NewTabFolder", "MiddleClickClosesTab", "ShowNavigationBar", "MaxTabTitleLength", "PingOpensNewTab", "MouseSideButtonsNavigate", "NewTabBesideActive", "ShelfOneShot", - "NewTabStartPage", "ShowStatusBar", "SearchChips", + "ShowStatusBar", "SearchChips", }; foreach (var key in keys) EditorPrefs.DeleteKey(Prefix + key); @@ -140,11 +133,6 @@ static void DrawGui() new GUIContent("Open New Tab Beside Active", "New tabs open right of the active tab instead of at the end of the bar."), TabstepSettings.NewTabBesideActive); - TabstepSettings.NewTabStartPage = EditorGUILayout.Toggle( - new GUIContent("New Tab Shows Start Page", - "A fresh tab (Ctrl+T) shows Quick Access, saved searches and recently " + - "closed tabs instead of jumping straight to a folder."), - TabstepSettings.NewTabStartPage); TabstepSettings.MaxTabTitleLength = EditorGUILayout.IntSlider( new GUIContent("Max Tab Title Length"), TabstepSettings.MaxTabTitleLength, 4, 60); diff --git a/Editor/Window/ProjectBrowserHost.cs b/Editor/Window/ProjectBrowserHost.cs index cac867b..0aebd3a 100644 --- a/Editor/Window/ProjectBrowserHost.cs +++ b/Editor/Window/ProjectBrowserHost.cs @@ -301,6 +301,24 @@ public void MarkAsLastInteracted() Invoke(SetAsLastInteractedMethod); } + /// + /// The asset list (right column / search results) rect in host-content + /// coordinates, zero when unknown. Lets the host tell list clicks apart from + /// folder-tree clicks. + /// + public Rect GetListAreaRect() + { + if (_browser == null || ListAreaRectField == null) return Rect.zero; + try + { + return (Rect)ListAreaRectField.GetValue(_browser); + } + catch + { + return Rect.zero; + } + } + /// Folder currently shown by the embedded browser, or null when unknown. public string GetActiveFolderPath() { diff --git a/Editor/Window/TabstepProjectWindow.cs b/Editor/Window/TabstepProjectWindow.cs index 4ce4522..b69e635 100644 --- a/Editor/Window/TabstepProjectWindow.cs +++ b/Editor/Window/TabstepProjectWindow.cs @@ -101,6 +101,10 @@ public class TabstepProjectWindow : EditorWindow bool _dragActive; bool _dragZoneVisible; + // A middle press in the embedded asset list was converted to a left click; + // the matching release opens the now-selected folder in a new tab. + bool _browserMiddleClickArmed; + // Spring-loading the nav buttons: hovering ◀ ▶ ▲ with a drag in flight navigates // after a moment, like Explorer — so a drag can walk back through the history. int _navSpringTarget; @@ -128,7 +132,6 @@ public class TabstepProjectWindow : EditorWindow string _statusSelectionText = ""; double _statusSelectionTime; - Vector2 _startPageScroll; bool _openWorkspacePopup; // "Save Tabs As..." defers the popup to the next OnGUI static GUIStyle _rightMiniLabel; @@ -163,17 +166,11 @@ static string GetSelectedFolderPath() return AssetDatabase.IsValidFolder(path) ? path : null; } - /// - /// Opens as a new active tab. With no folder given, - /// the new tab is the start page (when enabled) or the default folder. - /// + /// Opens (or the default folder) as a new active tab. public void OpenInNewTab(string folderPath) { - // An empty TabState (no history) is the start page; navigating fills it in. - var tab = folderPath == null && TabstepSettings.NewTabStartPage - ? new TabState() - : new TabState(ValidFolderOrDefault(folderPath)); - _session.AddTab(tab, TabstepSettings.NewTabBesideActive); + _session.AddTab(new TabState(ValidFolderOrDefault(folderPath)), + TabstepSettings.NewTabBesideActive); _applyTabToBrowser = true; Repaint(); } @@ -255,19 +252,56 @@ void OnGUI() var content = new Rect(0, top, position.width, position.height - top - statusHeight); if (content.height > 0) { - if (_session.ActiveTab != null && _session.ActiveTab.CurrentPath == null) - DrawStartPage(content); - else - // The navigation bar replaces the browser's path header (and, when the - // Harmony patches are active, its whole toolbar) — only while it's shown, - // so a path display and search always remain available. - _host.OnGUI(content, showNav); + bool middleClickReleased = ConvertBrowserMiddleClick(content); + // The navigation bar replaces the browser's path header (and, when the + // Harmony patches are active, its whole toolbar) — only while it's shown, + // so a path display and search always remain available. + _host.OnGUI(content, showNav); + if (middleClickReleased) OpenSelectedFolderInNewTab(); } if (showStatus) DrawStatusBar(new Rect(0, position.height - statusHeight, position.width, statusHeight)); DrawPathSuggestions(); } + /// + /// Middle-click on a folder in the embedded asset list opens it in a new tab. + /// The browser has no middle-click behaviour of its own, so the press is + /// converted into a left click — which makes the browser select whatever is + /// under the cursor — and the release reads that selection back. Restricted to + /// the list area: tree clicks navigate by themselves and must stay untouched. + /// Returns true on the (converted) release, after which the caller — once the + /// browser has processed the click — opens the selected folder. + /// + bool ConvertBrowserMiddleClick(Rect content) + { + var e = Event.current; + if (e.button != 2 || (e.type != EventType.MouseDown && e.type != EventType.MouseUp)) + return false; + var list = _host.GetListAreaRect(); + if (list.width <= 0f) return false; + var listRect = new Rect(content.x + list.x, content.y + list.y, list.width, list.height); + if (e.type == EventType.MouseDown) + { + // Only a press that starts in the list arms the release. + _browserMiddleClickArmed = listRect.Contains(e.mousePosition); + if (_browserMiddleClickArmed) e.button = 0; + return false; + } + if (!_browserMiddleClickArmed) return false; + _browserMiddleClickArmed = false; + if (!listRect.Contains(e.mousePosition)) return false; + e.button = 0; + return true; + } + + void OpenSelectedFolderInNewTab() + { + var path = AssetDatabase.GetAssetPath(Selection.activeObject); + if (!string.IsNullOrEmpty(path) && AssetDatabase.IsValidFolder(path)) + OpenInNewTab(path); + } + // ---- browser <-> tab sync -------------------------------------------- void SyncWithBrowser() @@ -278,13 +312,6 @@ void SyncWithBrowser() if (_applyTabToBrowser) { _applyTabToBrowser = false; - if (tab.CurrentPath == null) - { - // Start page: nothing to push to the browser. Baseline the observed - // path so a later external ping registers as a change (below). - _observedBrowserPath = _host.GetActiveFolderPath(); - return; - } if (!_host.ShowFolder(tab.CurrentPath)) { // Folder was deleted/renamed while the tab pointed at it. @@ -302,19 +329,6 @@ void SyncWithBrowser() return; } - if (tab.CurrentPath == null) - { - // Start page is showing. A ping / "Show in Project" from outside drove - // the hidden browser somewhere — adopt that folder into this empty tab. - var external = _host.GetActiveFolderPath(); - if (external != null && external != _observedBrowserPath) - { - _observedBrowserPath = external; - tab.Navigate(external); - } - return; - } - // Mirror the browser's live filter into the active tab (typed into either // search field, or cleared by the browser when a folder is clicked). var browserSearch = _host.GetSearchText(); @@ -1289,7 +1303,7 @@ static List DraggedProjectPaths() return paths; } - /// Active tab's folder, or null on a start page — the shelf hands assets off here. + /// Active tab's folder — the shelf hands assets off here. internal string ActiveFolderPath => _session.ActiveTab?.CurrentPath; /// Moves assets into the active tab's folder (used by the shelf's "→ Tab"). @@ -1648,22 +1662,8 @@ void DrawAddressBar(Rect rect, TabState tab) var inner = new Rect(rect.x + 4, rect.y, rect.width - 8, rect.height); float x = inner.x; - if (tab.CurrentPath == null) - { - // Start page tab — no crumbs yet; the bar invites typing a path. - GUI.Label(inner, "New Tab — type a path (Ctrl+L) or pick a folder below", - EditorStyles.centeredGreyMiniLabel); - EditorGUIUtility.AddCursorRect(inner, MouseCursor.Text); - if (e.type == EventType.MouseDown && e.button == 0 && inner.Contains(e.mousePosition)) - { - e.Use(); - BeginPathEdit(); - } - return; - } - // Folder icon at the left, like Explorer's address bar. - var icon = AssetDatabase.GetCachedIcon(tab.CurrentPath); + var icon = tab.CurrentPath != null ? AssetDatabase.GetCachedIcon(tab.CurrentPath) : null; if (icon != null) { GUI.DrawTexture(new Rect(x, inner.y + (inner.height - 16) / 2, 16, 16), icon, @@ -1862,115 +1862,6 @@ void DrawPathField(Rect rect) } } - // ---- start page ---------------------------------------------------------- - - /// - /// What an empty tab shows instead of the embedded browser: Quick Access, - /// saved searches and recently closed tabs. Clicking navigates this tab; - /// middle-clicking opens a new one. - /// - void DrawStartPage(Rect content) - { - GUILayout.BeginArea(content); - _startPageScroll = EditorGUILayout.BeginScrollView(_startPageScroll); - EditorGUILayout.Space(10); - - GUILayout.Label("Quick Access", EditorStyles.boldLabel); - if (TabstepBookmarks.Folders.Count == 0) - GUILayout.Label("Right-click a tab (or the + button) to add folders here.", - EditorStyles.miniLabel); - else - DrawStartFolderButtons(TabstepBookmarks.Folders); - - if (TabstepBookmarks.Searches.Count > 0) - { - EditorGUILayout.Space(10); - GUILayout.Label("Saved Searches", EditorStyles.boldLabel); - foreach (var saved in TabstepBookmarks.Searches) - { - var entry = saved; - bool valid = AssetDatabase.IsValidFolder(entry.folder); - var label = new GUIContent($"“{entry.search}” — {entry.folder}", entry.folder); - using (new EditorGUI.DisabledScope(!valid)) - if (GUILayout.Button(label, GUILayout.Height(22))) - { - if (Event.current.button == 2) - { - OpenSavedSearchInNewTab(entry.folder, entry.search); - } - else - { - var tab = _session.ActiveTab; - tab.Navigate(entry.folder); - tab.SearchText = entry.search; - _applyTabToBrowser = true; - Repaint(); - } - } - } - } - - DrawStartRecentlyClosed(); - - EditorGUILayout.EndScrollView(); - GUILayout.EndArea(); - } - - void DrawStartFolderButtons(IReadOnlyList folders) - { - const float buttonWidth = 180f; - int perRow = Mathf.Max(1, (int)((position.width - 20) / (buttonWidth + 6))); - int i = 0; - while (i < folders.Count) - { - EditorGUILayout.BeginHorizontal(); - for (int column = 0; column < perRow && i < folders.Count; column++, i++) - DrawStartFolderButton(folders[i], buttonWidth); - GUILayout.FlexibleSpace(); - EditorGUILayout.EndHorizontal(); - } - } - - void DrawStartFolderButton(string path, float width) - { - bool valid = AssetDatabase.IsValidFolder(path); - var icon = valid ? AssetDatabase.GetCachedIcon(path) : null; - var content = new GUIContent(" " + (ProjectPaths.GetDisplayName(path) ?? path), icon, path); - using (new EditorGUI.DisabledScope(!valid)) - if (GUILayout.Button(content, GUILayout.Width(width), GUILayout.Height(24))) - { - if (Event.current.button == 2) OpenInNewTab(path); - else NavigateTo(path); - } - } - - void DrawStartRecentlyClosed() - { - var closed = _session.RecentlyClosed; - bool any = false; - int shown = 0; - for (int i = closed.Count - 1; i >= 0 && shown < 5; i--) - { - var path = closed[i].CurrentPath; - if (path == null) continue; // closed start pages are not worth listing - if (!any) - { - EditorGUILayout.Space(10); - GUILayout.Label("Recently Closed", EditorStyles.boldLabel); - any = true; - } - shown++; - bool valid = AssetDatabase.IsValidFolder(path); - var icon = valid ? AssetDatabase.GetCachedIcon(path) : null; - using (new EditorGUI.DisabledScope(!valid)) - if (GUILayout.Button(new GUIContent(" " + path, icon), GUILayout.Height(22))) - { - if (Event.current.button == 2) OpenInNewTab(path); - else NavigateTo(path); - } - } - } - // ---- status bar ---------------------------------------------------------- /// Bottom row: item count of the shown folder, and the selection summary. diff --git a/Editor/Window/TabstepShelfWindow.cs b/Editor/Window/TabstepShelfWindow.cs index f06a9f2..598fd7b 100644 --- a/Editor/Window/TabstepShelfWindow.cs +++ b/Editor/Window/TabstepShelfWindow.cs @@ -71,10 +71,13 @@ public static TabstepShelfWindow ShowNear(EditorWindow anchor) var window = Instance; if (window == null) { + // The alive flag must be up before CreateInstance: OnEnable runs inside + // it, and an unset flag reads as "ghost from a saved layout" — which + // would close the shelf the moment it appears. + SessionState.SetBool(AliveKey, true); window = CreateInstance(); window.PositionNear(anchor); window.ShowPopup(); - SessionState.SetBool(AliveKey, true); } window.Repaint(); return window; diff --git a/README.md b/README.md index be171f4..dd4a779 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,10 @@ like the built-in Project window. On top of it, Tabstep adds: the last closed tab with its full history; the last 10 are remembered. - **Back / forward history per tab** — navigate like a web browser, including with the mouse side (thumb) buttons. Right-click ◀ / ▶ for the full history - as a dropdown; middle-click ◀ / ▶ / ▲ (or a breadcrumb segment) to open the - destination in a new tab. Hovering ◀ / ▶ / ▲ while dragging assets - spring-loads the navigation, so a drag can walk through the history. + as a dropdown. Hovering ◀ / ▶ / ▲ while dragging assets spring-loads the + navigation, so a drag can walk through the history. +- **Middle-click opens a new tab** — on a folder in the asset list, on a + breadcrumb segment, on ◀ / ▶ / ▲ and on path suggestions, like browser links. - **Per-tab search** — each tab remembers its own search filter and restores it when the tab becomes active again. Configurable filter chips next to the search field toggle `t:` filters with one click. @@ -33,8 +34,6 @@ like the built-in Project window. On top of it, Tabstep adds: saved searches* (a filter pinned to the folder it was made in); add either from that menu or from a tab's context menu. Stored per user and per project in `UserSettings/`. -- **Start page** — a fresh tab (`Ctrl+T`) shows Quick Access, saved searches - and recently closed tabs instead of jumping to a folder (toggleable). - **Workspaces** — save the whole tab set (order, pins, histories, searches) under a name via the `▾` dropdown and restore it any time. - **Undo asset moves** — moved something onto the wrong tab? *Undo Last Asset @@ -131,9 +130,9 @@ Open via `YozoLab > Tabstep`. | Right-click path bar | Copy path / copy absolute path / paste path / edit path | Preferences live under `Edit > Preferences > Yozolab > Tabstep` -(new-tab folder, new-tab position, start page, navigation bar, status bar, -search filter chips, tab title length, middle-click close, mouse side -buttons, ping behaviour, shelf one-shot). +(new-tab folder, new-tab position, navigation bar, status bar, search filter +chips, tab title length, middle-click close, mouse side buttons, ping +behaviour, shelf one-shot). ## Harmony (optional but recommended) diff --git a/Tests/TabSessionTests.cs b/Tests/TabSessionTests.cs index 45d34cd..2eea7aa 100644 --- a/Tests/TabSessionTests.cs +++ b/Tests/TabSessionTests.cs @@ -269,7 +269,7 @@ public void AddTab_BesideActivePinnedTab_LandsOutsideThePinnedRegion() } [Test] - public void AddTab_StartPageTab_HasNoPathUntilNavigated() + public void AddTab_EmptyTab_HasNoPathUntilNavigated() { var session = SessionWith("Assets"); var tab = session.AddTab(new TabState(), besideActive: false); From b09dc0e4c066d5dcb53345c5b65e53046c4d07e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:33:00 +0000 Subject: [PATCH 4/8] feat: screen-anchored shelf dragging and a global Ctrl+Shift+X summon - Keep the shelf frameless (ShowPopup) per feedback - no OS title bar. Fix the janky header drag instead: the move is now anchored in screen space (start point remembered once, origin re-derived from the absolute cursor position) rather than accumulating per-event deltas whose frame of reference shifts under the moving window. - Ctrl+Shift+X (Shortcut Manager: Tabstep/Summon Shelf, rebindable, works from any editor window) summons the shelf to the mouse position - opening it there or moving the existing one - and adds the current selection (assets, folders, scene objects) to it in the same stroke. --- Editor/Window/TabstepShelfWindow.cs | 125 +++++++++++++++++++++++----- README.md | 5 ++ 2 files changed, 110 insertions(+), 20 deletions(-) diff --git a/Editor/Window/TabstepShelfWindow.cs b/Editor/Window/TabstepShelfWindow.cs index 598fd7b..1d64574 100644 --- a/Editor/Window/TabstepShelfWindow.cs +++ b/Editor/Window/TabstepShelfWindow.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using UnityEditor; +using UnityEditor.ShortcutManagement; using UnityEditorInternal; using UnityEngine; using Object = UnityEngine.Object; @@ -7,14 +8,18 @@ namespace Yozolab.Tabstep { /// - /// The Tabstep Shelf: a small borderless float window that parks objects mid-flight. - /// Drop assets (or scene objects) on it, then drag them back out onto another tab, - /// an Inspector object field, the scene — anywhere Unity drag & drop reaches. + /// The Tabstep Shelf: a small borderless float window that parks objects + /// mid-flight. Drop assets (or scene objects) on it, then drag them back out onto + /// another tab, an Inspector object field, the scene — anywhere Unity drag & + /// drop reaches. /// - /// Shown with ShowPopup so it never steals focus while switching tabs underneath it. - /// Items survive domain reloads via window serialization; the editor-restart ghost - /// a popup window would otherwise leave behind is detected via SessionState (which - /// is cleared on restart, matching the shelf's "temporary" semantics) and closed. + /// Shown with ShowPopup: an independent frameless window floating above the + /// editor; its header row moves it (anchored in screen space, so the drag stays + /// glued to the cursor). Ctrl+Shift+X — a global, rebindable shortcut — summons + /// it to the mouse and drops the current selection onto it. Items survive domain + /// reloads via window serialization; a ghost popup restored at editor startup is + /// detected via SessionState (which is cleared on restart, matching the shelf's + /// "temporary" semantics) and closed. /// /// With the One-Shot preference on, dragging an item out consumes it: the item /// disappears when the drag leaves the shelf and comes back only if the same drag @@ -36,6 +41,10 @@ class TabstepShelfWindow : EditorWindow Vector2 _scroll; ShelfItem _mouseDownItem; bool _dragAllArmed; + // Header drag-to-move state, anchored in screen space (see HandleWindowDrag). + bool _draggingWindow; + Vector2 _dragStartMouseScreen; + Vector2 _dragStartWindowOrigin; // One-shot bookkeeping: the item(s) consumed by the current drag-out, restored // if the very same drag re-enters the shelf and drops. readonly List _draggedOut = new List(); @@ -71,18 +80,71 @@ public static TabstepShelfWindow ShowNear(EditorWindow anchor) var window = Instance; if (window == null) { - // The alive flag must be up before CreateInstance: OnEnable runs inside - // it, and an unset flag reads as "ghost from a saved layout" — which - // would close the shelf the moment it appears. - SessionState.SetBool(AliveKey, true); - window = CreateInstance(); + window = CreateNew(); window.PositionNear(anchor); - window.ShowPopup(); } window.Repaint(); return window; } + /// + /// The global Ctrl+Shift+X gesture: brings the shelf (existing or new) to the + /// mouse and drops the current selection onto it. Rebindable in the Shortcut + /// Manager; works no matter which editor window has focus. + /// + [Shortcut("Tabstep/Summon Shelf", KeyCode.X, ShortcutModifiers.Action | ShortcutModifiers.Shift)] + static void SummonShortcut() + { + var window = SummonAt(GlobalMousePosition()); + if (Selection.objects.Length > 0) + window.AddObjects(Selection.objects); + } + + /// Opens the shelf at (or moves it to) a screen point, keeping its size. + public static TabstepShelfWindow SummonAt(Vector2 screenPoint) + { + var window = Instance; + var origin = screenPoint - new Vector2(40f, 12f); // header lands under the cursor + if (window == null) + { + window = CreateNew(); + window.position = new Rect(origin, DefaultSize); + } + else + { + window.position = new Rect(origin, window.position.size); + window.Focus(); + } + window.Repaint(); + return window; + } + + static TabstepShelfWindow CreateNew() + { + // The alive flag must be up before CreateInstance: OnEnable runs inside + // it, and an unset flag reads as "ghost restored at startup" — which + // would close the shelf the moment it appears. + SessionState.SetBool(AliveKey, true); + var window = CreateInstance(); + // ShowPopup: independent and frameless; it floats above the editor. + window.ShowPopup(); + return window; + } + + /// + /// Best available mouse position in screen coordinates. Shortcut handlers run + /// during event processing, so Event.current is usually there; the fallbacks + /// degrade to the hovered window, then the main window. + /// + static Vector2 GlobalMousePosition() + { + if (Event.current != null) + return GUIUtility.GUIToScreenPoint(Event.current.mousePosition); + var hovered = mouseOverWindow; + if (hovered != null) return hovered.position.center; + return EditorGUIUtility.GetMainWindowPosition().center; + } + public static void Toggle(EditorWindow anchor) { if (IsOpen) Instance.Close(); @@ -167,16 +229,39 @@ void DrawHeader() GUIUtility.ExitGUI(); } EditorGUILayout.EndHorizontal(); + HandleWindowDrag(GUILayoutUtility.GetLastRect()); + } - var headerRect = GUILayoutUtility.GetLastRect(); + /// + /// Dragging the empty part of the header moves the window. Anchored in screen + /// space — start point remembered once, each drag re-derives the origin from + /// the absolute cursor position — instead of accumulating per-event deltas, + /// whose frame of reference shifts under the moving window and jitters. + /// + void HandleWindowDrag(Rect headerRect) + { var e = Event.current; - // Dragging the empty part of the header moves the popup window. - if (e.type == EventType.MouseDrag && e.button == 0 && - _mouseDownItem == null && !_dragAllArmed && - headerRect.Contains(e.mousePosition - e.delta)) + switch (e.type) { - position = new Rect(position.position + e.delta, position.size); - e.Use(); + // Button clicks in the header never get here: the buttons consume + // their own MouseDown first. + case EventType.MouseDown when e.button == 0 && headerRect.Contains(e.mousePosition): + _draggingWindow = true; + _dragStartMouseScreen = GUIUtility.GUIToScreenPoint(e.mousePosition); + _dragStartWindowOrigin = position.position; + e.Use(); + break; + case EventType.MouseDrag when _draggingWindow: + var mouseScreen = GUIUtility.GUIToScreenPoint(e.mousePosition); + position = new Rect( + _dragStartWindowOrigin + (mouseScreen - _dragStartMouseScreen), + position.size); + e.Use(); + Repaint(); + break; + case EventType.MouseUp: + _draggingWindow = false; + break; } } diff --git a/README.md b/README.md index dd4a779..f8f4814 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,10 @@ another tab's folder, an Inspector object field, the Hierarchy, the Scene view. - **Getting things onto it** — drop them on the shelf window, drop them on the `▼ Shelf` zone that appears at the right end of the tab bar while you drag assets, or use *Send Selection to Shelf* in a tab's context menu. +- **`Ctrl+Shift+X` summons it** — a global, rebindable shortcut (Shortcut + Manager: *Tabstep/Summon Shelf*) that works from any editor window: the + shelf jumps to the mouse position, and whatever assets, folders or scene + objects are selected get added to it in the same stroke. - **One-shot by default** — dragging an item out consumes it: the shelf is a hand-off, not a copy source. Dragging it back before dropping restores it; the behaviour is toggleable in preferences. @@ -122,6 +126,7 @@ Open via `YozoLab > Tabstep`. | `Ctrl+F` | Focus the search field (when it lives in the navigation bar) | | `Ctrl+Shift+C` | Copy the current folder's absolute path | | `Ctrl+Shift+D` | Toggle the shelf | +| `Ctrl+Shift+X` | Summon the shelf to the mouse and add the selection (global, rebindable) | | Drag tab | Reorder tabs | | Middle-click tab | Close tab | | Right-click tab | Pin / quick access / shelf / close others / duplicate / copy & paste path | From 9f36ad4de39ea5f8dca49d3048a1ce4ea9d55231 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:42:23 +0000 Subject: [PATCH 5/8] fix: make the shelf a regular floating editor window; harden Ctrl+Shift+X - The shelf is now a normal floating EditorWindow (Show()), treated like the Tabstep window itself: native dragging by its title tab (the custom header drag died whenever the cursor left the window), standard editor z-order (floats above the main window), dockable, and saved into the layout. The popup-era chrome - custom border, close button, drag-to-move, SessionState ghost detection - is gone with it. - Ctrl+Shift+X: the Shortcut Manager binding stays, and the Tabstep window now handles the same combination itself as a fallback for setups where the binding is shadowed (no double-trigger: when the global binding fires, the window never sees the key). Summoning a docked shelf focuses it instead of trying to move it; the mouse-position lookup no longer dies silently without a GUI context. --- Editor/Window/TabstepProjectWindow.cs | 8 ++ Editor/Window/TabstepShelfWindow.cs | 134 +++++++------------------- README.md | 14 +-- 3 files changed, 53 insertions(+), 103 deletions(-) diff --git a/Editor/Window/TabstepProjectWindow.cs b/Editor/Window/TabstepProjectWindow.cs index b69e635..0be3aa9 100644 --- a/Editor/Window/TabstepProjectWindow.cs +++ b/Editor/Window/TabstepProjectWindow.cs @@ -441,6 +441,14 @@ void HandleShortcuts() TabstepShelfWindow.Toggle(this); e.Use(); } + else if (ctrl && e.shift && e.keyCode == KeyCode.X) + { + // Fallback for the global "Tabstep/Summon Shelf" shortcut — covers + // setups where the Shortcut Manager binding is shadowed. When the + // global binding fires first, this window never sees the key. + TabstepShelfWindow.SummonToMouse(); + e.Use(); + } else if (ctrl && !e.shift && e.keyCode >= KeyCode.Alpha1 && e.keyCode <= KeyCode.Alpha9) { // Ctrl+1..8 jump to that tab, Ctrl+9 to the last one (browser convention). diff --git a/Editor/Window/TabstepShelfWindow.cs b/Editor/Window/TabstepShelfWindow.cs index 1d64574..c6e70d0 100644 --- a/Editor/Window/TabstepShelfWindow.cs +++ b/Editor/Window/TabstepShelfWindow.cs @@ -8,18 +8,16 @@ namespace Yozolab.Tabstep { /// - /// The Tabstep Shelf: a small borderless float window that parks objects - /// mid-flight. Drop assets (or scene objects) on it, then drag them back out onto - /// another tab, an Inspector object field, the scene — anywhere Unity drag & - /// drop reaches. + /// The Tabstep Shelf: a small float window that parks objects mid-flight. Drop + /// assets (or scene objects) on it, then drag them back out onto another tab, an + /// Inspector object field, the scene — anywhere Unity drag & drop reaches. /// - /// Shown with ShowPopup: an independent frameless window floating above the - /// editor; its header row moves it (anchored in screen space, so the drag stays - /// glued to the cursor). Ctrl+Shift+X — a global, rebindable shortcut — summons - /// it to the mouse and drops the current selection onto it. Items survive domain - /// reloads via window serialization; a ghost popup restored at editor startup is - /// detected via SessionState (which is cleared on restart, matching the shelf's - /// "temporary" semantics) and closed. + /// A regular floating EditorWindow, treated exactly like the Tabstep window + /// itself: it moves by its own title tab, floats above the main editor window + /// (standard Unity z-order for floating windows), can be docked, and survives + /// restarts through the layout. Ctrl+Shift+X — a global, rebindable shortcut, + /// with a fallback handler inside the Tabstep window — summons it to the mouse + /// and drops the current selection onto it. /// /// With the One-Shot preference on, dragging an item out consumes it: the item /// disappears when the drag leaves the shelf and comes back only if the same drag @@ -29,7 +27,6 @@ class TabstepShelfWindow : EditorWindow { const string DragKey = "Tabstep.ShelfDrag"; const string AllKey = "*"; // generic-data value for the drag-all handle - const string AliveKey = "Tabstep.ShelfAlive"; const float RowHeight = 22f; static readonly Vector2 DefaultSize = new Vector2(230, 190); @@ -41,10 +38,6 @@ class TabstepShelfWindow : EditorWindow Vector2 _scroll; ShelfItem _mouseDownItem; bool _dragAllArmed; - // Header drag-to-move state, anchored in screen space (see HandleWindowDrag). - bool _draggingWindow; - Vector2 _dragStartMouseScreen; - Vector2 _dragStartWindowOrigin; // One-shot bookkeeping: the item(s) consumed by the current drag-out, restored // if the very same drag re-enters the shelf and drops. readonly List _draggedOut = new List(); @@ -90,10 +83,17 @@ public static TabstepShelfWindow ShowNear(EditorWindow anchor) /// /// The global Ctrl+Shift+X gesture: brings the shelf (existing or new) to the /// mouse and drops the current selection onto it. Rebindable in the Shortcut - /// Manager; works no matter which editor window has focus. + /// Manager; the Tabstep window also handles the same combination itself as a + /// fallback, in case the binding is shadowed in a given setup. /// [Shortcut("Tabstep/Summon Shelf", KeyCode.X, ShortcutModifiers.Action | ShortcutModifiers.Shift)] static void SummonShortcut() + { + SummonToMouse(); + } + + /// Summons the shelf to the mouse and adds the current selection to it. + internal static void SummonToMouse() { var window = SummonAt(GlobalMousePosition()); if (Selection.objects.Length > 0) @@ -104,7 +104,7 @@ static void SummonShortcut() public static TabstepShelfWindow SummonAt(Vector2 screenPoint) { var window = Instance; - var origin = screenPoint - new Vector2(40f, 12f); // header lands under the cursor + var origin = screenPoint - new Vector2(40f, 12f); // lands under the cursor if (window == null) { window = CreateNew(); @@ -112,7 +112,9 @@ public static TabstepShelfWindow SummonAt(Vector2 screenPoint) } else { - window.position = new Rect(origin, window.position.size); + // A docked shelf has no position of its own — just focus it there. + if (!window.docked) + window.position = new Rect(origin, window.position.size); window.Focus(); } window.Repaint(); @@ -121,25 +123,29 @@ public static TabstepShelfWindow SummonAt(Vector2 screenPoint) static TabstepShelfWindow CreateNew() { - // The alive flag must be up before CreateInstance: OnEnable runs inside - // it, and an unset flag reads as "ghost restored at startup" — which - // would close the shelf the moment it appears. - SessionState.SetBool(AliveKey, true); var window = CreateInstance(); - // ShowPopup: independent and frameless; it floats above the editor. - window.ShowPopup(); + // Show(): a regular floating editor window — native dragging by its title + // tab, always above the main window, dockable, saved into the layout. + window.Show(); return window; } /// - /// Best available mouse position in screen coordinates. Shortcut handlers run - /// during event processing, so Event.current is usually there; the fallbacks - /// degrade to the hovered window, then the main window. + /// Best available mouse position in screen coordinates. Shortcut and fallback + /// handlers run during event processing, so Event.current is usually there; + /// failing that this degrades to the hovered window, then the main window. /// static Vector2 GlobalMousePosition() { - if (Event.current != null) - return GUIUtility.GUIToScreenPoint(Event.current.mousePosition); + try + { + if (Event.current != null) + return GUIUtility.GUIToScreenPoint(Event.current.mousePosition); + } + catch + { + // No live GUI clip stack — fall through to the window-based guesses. + } var hovered = mouseOverWindow; if (hovered != null) return hovered.position.center; return EditorGUIUtility.GetMainWindowPosition().center; @@ -168,13 +174,6 @@ void OnEnable() titleContent = new GUIContent("Tabstep Shelf"); minSize = new Vector2(160, 100); wantsMouseMove = true; - // A popup re-created from a saved layout at editor startup is a ghost: - // SessionState is empty again, while a domain reload would have kept it. - if (!SessionState.GetBool(AliveKey, false)) - EditorApplication.delayCall += () => - { - if (this != null) Close(); - }; } void OnDisable() @@ -182,11 +181,6 @@ void OnDisable() if (_instance == this) _instance = null; } - void OnDestroy() - { - SessionState.SetBool(AliveKey, false); - } - void PositionNear(EditorWindow anchor) { var main = EditorGUIUtility.GetMainWindowPosition(); @@ -202,16 +196,14 @@ void OnGUI() HandleDragEvents(); DrawHeader(); DrawItems(); - DrawBorder(); } // ---- chrome ------------------------------------------------------------ - /// Title row. A popup has no OS title bar, so this row also moves the window. + /// Toolbar row — moving and closing belong to the window's own tab. void DrawHeader() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); - GUILayout.Label("Shelf", EditorStyles.boldLabel, GUILayout.ExpandWidth(false)); DrawDragAllHandle(); GUILayout.FlexibleSpace(); DrawMoveToTabButton(); @@ -222,47 +214,7 @@ void DrawHeader() if (GUILayout.Button(new GUIContent("Clear", "Remove all items"), EditorStyles.toolbarButton, GUILayout.ExpandWidth(false))) _items.Clear(); - if (GUILayout.Button(new GUIContent("×", "Close the shelf"), - EditorStyles.toolbarButton, GUILayout.Width(22))) - { - Close(); - GUIUtility.ExitGUI(); - } EditorGUILayout.EndHorizontal(); - HandleWindowDrag(GUILayoutUtility.GetLastRect()); - } - - /// - /// Dragging the empty part of the header moves the window. Anchored in screen - /// space — start point remembered once, each drag re-derives the origin from - /// the absolute cursor position — instead of accumulating per-event deltas, - /// whose frame of reference shifts under the moving window and jitters. - /// - void HandleWindowDrag(Rect headerRect) - { - var e = Event.current; - switch (e.type) - { - // Button clicks in the header never get here: the buttons consume - // their own MouseDown first. - case EventType.MouseDown when e.button == 0 && headerRect.Contains(e.mousePosition): - _draggingWindow = true; - _dragStartMouseScreen = GUIUtility.GUIToScreenPoint(e.mousePosition); - _dragStartWindowOrigin = position.position; - e.Use(); - break; - case EventType.MouseDrag when _draggingWindow: - var mouseScreen = GUIUtility.GUIToScreenPoint(e.mousePosition); - position = new Rect( - _dragStartWindowOrigin + (mouseScreen - _dragStartMouseScreen), - position.size); - e.Use(); - Repaint(); - break; - case EventType.MouseUp: - _draggingWindow = false; - break; - } } /// Drag this handle to carry every (living) item out in one drag. @@ -335,18 +287,6 @@ void DrawMoveToTabButton() } } - /// Popup windows have no frame; a 1px outline keeps the shelf readable. - void DrawBorder() - { - if (Event.current.type != EventType.Repaint) return; - var color = EditorGUIUtility.isProSkin ? new Color(0.1f, 0.1f, 0.1f) : new Color(0.4f, 0.4f, 0.4f); - var full = new Rect(0, 0, position.width, position.height); - EditorGUI.DrawRect(new Rect(full.x, full.y, full.width, 1), color); - EditorGUI.DrawRect(new Rect(full.x, full.yMax - 1, full.width, 1), color); - EditorGUI.DrawRect(new Rect(full.x, full.y, 1, full.height), color); - EditorGUI.DrawRect(new Rect(full.xMax - 1, full.y, 1, full.height), color); - } - // ---- items ------------------------------------------------------------- void DrawItems() diff --git a/README.md b/README.md index f8f4814..3f5b46a 100644 --- a/README.md +++ b/README.md @@ -92,12 +92,14 @@ another tab's folder, an Inspector object field, the Hierarchy, the Scene view. - **Component relay** — drop a component (drag its Inspector header) on the shelf, then right-click it to paste a copy onto the selected GameObjects. Right-click also offers ping / select / copy for every item. -- **Stays out of the way** — shown as a borderless popup that never steals - focus, so you can switch tabs underneath it mid-drag. Drag its title row to - move it. It survives domain reloads, closes with its Tabstep window (unless - pinned with the `Pin` button), and an emptied unpinned shelf closes itself. -- Items are stored as GUIDs / GlobalObjectIds, so they survive domain reloads; - scene objects resolve while their scene is open and grey out otherwise. +- **A window like any other** — a regular floating editor window: move it by + its title tab, dock it anywhere, and it stays above the main window like + every floating Unity window. It is saved into the layout, closes with its + Tabstep window (unless pinned with the `Pin` button), and an emptied + unpinned shelf closes itself. +- Items are stored as GUIDs / GlobalObjectIds, so they survive domain reloads + and restarts; scene objects resolve while their scene is open and grey out + otherwise. ## Tabstep Inspector (parked) From fa2af280e8b3a7235aff6d5ce4c919d31810b064 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:49:13 +0000 Subject: [PATCH 6/8] fix: shelf summoned by Ctrl+Shift+X could land off-screen and look dead Shortcut handlers run outside any GUI context, where GUIToScreenPoint silently applies a stale view offset - the shelf then opens at a garbage position with no error, indistinguishable from the shortcut not firing. - On Windows, ask the OS cursor directly (GetCursorPos, scaled by pixelsPerPoint) - the one source that is correct in a shortcut handler. - Sanity-check the summon point: anything implausibly far from the main window lands on its center instead. - Focus the freshly created window as well as the existing one. --- Editor/Window/TabstepShelfWindow.cs | 45 ++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/Editor/Window/TabstepShelfWindow.cs b/Editor/Window/TabstepShelfWindow.cs index c6e70d0..5b7dadb 100644 --- a/Editor/Window/TabstepShelfWindow.cs +++ b/Editor/Window/TabstepShelfWindow.cs @@ -1,4 +1,7 @@ using System.Collections.Generic; +#if UNITY_EDITOR_WIN +using System.Runtime.InteropServices; +#endif using UnityEditor; using UnityEditor.ShortcutManagement; using UnityEditorInternal; @@ -103,6 +106,15 @@ internal static void SummonToMouse() /// Opens the shelf at (or moves it to) a screen point, keeping its size. public static TabstepShelfWindow SummonAt(Vector2 screenPoint) { + // Shortcut handlers run outside any GUI context, where IMGUI's coordinate + // conversion can silently produce a far-off point from a stale view offset. + // A window placed there looks exactly like "the shortcut did nothing" — + // anything implausible lands on the main window instead. + var main = EditorGUIUtility.GetMainWindowPosition(); + var plausible = Rect.MinMaxRect(main.xMin - 4096, main.yMin - 4096, + main.xMax + 4096, main.yMax + 4096); + if (!plausible.Contains(screenPoint)) screenPoint = main.center; + var window = Instance; var origin = screenPoint - new Vector2(40f, 12f); // lands under the cursor if (window == null) @@ -110,13 +122,12 @@ public static TabstepShelfWindow SummonAt(Vector2 screenPoint) window = CreateNew(); window.position = new Rect(origin, DefaultSize); } - else + else if (!window.docked) { - // A docked shelf has no position of its own — just focus it there. - if (!window.docked) - window.position = new Rect(origin, window.position.size); - window.Focus(); + // A docked shelf has no position of its own — it only gets focused. + window.position = new Rect(origin, window.position.size); } + window.Focus(); window.Repaint(); return window; } @@ -131,12 +142,18 @@ static TabstepShelfWindow CreateNew() } /// - /// Best available mouse position in screen coordinates. Shortcut and fallback - /// handlers run during event processing, so Event.current is usually there; - /// failing that this degrades to the hovered window, then the main window. + /// Best available mouse position in screen coordinates. On Windows the OS + /// cursor is asked directly — the one source that works in a shortcut handler, + /// which runs outside any GUI context. Elsewhere Event.current is used when + /// alive, degrading to the hovered window, then the main window. /// static Vector2 GlobalMousePosition() { +#if UNITY_EDITOR_WIN + // GetCursorPos reports physical pixels; editor rects use scaled points. + if (GetCursorPos(out var cursor)) + return new Vector2(cursor.x, cursor.y) / EditorGUIUtility.pixelsPerPoint; +#endif try { if (Event.current != null) @@ -151,6 +168,18 @@ static Vector2 GlobalMousePosition() return EditorGUIUtility.GetMainWindowPosition().center; } +#if UNITY_EDITOR_WIN + [StructLayout(LayoutKind.Sequential)] + struct Win32Point + { + public int x; + public int y; + } + + [DllImport("user32.dll")] + static extern bool GetCursorPos(out Win32Point point); +#endif + public static void Toggle(EditorWindow anchor) { if (IsOpen) Instance.Close(); From a6eee94138b1662ac2277d05b7faac127af3c5e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:54:13 +0000 Subject: [PATCH 7/8] chore: trace when the shelf summon point is implausible --- Editor/Window/TabstepShelfWindow.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Editor/Window/TabstepShelfWindow.cs b/Editor/Window/TabstepShelfWindow.cs index 5b7dadb..8d5b3d4 100644 --- a/Editor/Window/TabstepShelfWindow.cs +++ b/Editor/Window/TabstepShelfWindow.cs @@ -113,7 +113,13 @@ public static TabstepShelfWindow SummonAt(Vector2 screenPoint) var main = EditorGUIUtility.GetMainWindowPosition(); var plausible = Rect.MinMaxRect(main.xMin - 4096, main.yMin - 4096, main.xMax + 4096, main.yMax + 4096); - if (!plausible.Contains(screenPoint)) screenPoint = main.center; + if (!plausible.Contains(screenPoint)) + { + // Worth a trace: it means every mouse-position source failed. + Debug.LogWarning($"[Tabstep] Shelf summon point {screenPoint} looked invalid; " + + "falling back to the main window center."); + screenPoint = main.center; + } var window = Instance; var origin = screenPoint - new Vector2(40f, 12f); // lands under the cursor From cc1b449e92e3ff9ed724eba16e8a4306f9e2e573 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:57:30 +0000 Subject: [PATCH 8/8] feat: consolidate shelf summoning onto Ctrl+Shift+D Ctrl+Shift+X never fired in the field while the in-window Ctrl+Shift+D did, so everything now lives on one combination: Ctrl+Shift+D summons the shelf to the mouse and adds the current selection. The Shortcut Manager binding (Tabstep/Summon Shelf) is rebound to D for setups where it does fire globally; the Tabstep window keeps the same behaviour as its fallback handler. The old toggle behaviour remains available via the Shelf button in the navigation bar. --- Editor/Window/TabstepProjectWindow.cs | 11 ++++------- Editor/Window/TabstepShelfWindow.cs | 6 +++--- README.md | 12 ++++++------ 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/Editor/Window/TabstepProjectWindow.cs b/Editor/Window/TabstepProjectWindow.cs index 0be3aa9..6984f99 100644 --- a/Editor/Window/TabstepProjectWindow.cs +++ b/Editor/Window/TabstepProjectWindow.cs @@ -21,7 +21,8 @@ namespace Yozolab.Tabstep /// Ctrl(+Shift)+Tab cycle tabs, Ctrl+1..9 jump to a tab (9 = last), /// Alt+Left/Right or mouse side buttons back/forward, Alt+Up parent folder, /// Ctrl+L / Alt+D edit the path, Ctrl+F focus the search field, - /// Ctrl+Shift+C copy the absolute path, Ctrl+Shift+D toggle the shelf. + /// Ctrl+Shift+C copy the absolute path, Ctrl+Shift+D summon the shelf + /// to the mouse (adding the selection). /// public class TabstepProjectWindow : EditorWindow { @@ -437,11 +438,6 @@ void HandleShortcuts() e.Use(); } else if (ctrl && e.shift && e.keyCode == KeyCode.D) - { - TabstepShelfWindow.Toggle(this); - e.Use(); - } - else if (ctrl && e.shift && e.keyCode == KeyCode.X) { // Fallback for the global "Tabstep/Summon Shelf" shortcut — covers // setups where the Shortcut Manager binding is shadowed. When the @@ -1582,7 +1578,8 @@ void DrawShelfToggle() bool now = GUILayout.Toggle(open, new GUIContent("Shelf", "The shelf — a temporary tray for assets in transit between tabs, " + - "Inspector fields and the scene (Ctrl+Shift+D)"), + "Inspector fields and the scene. Ctrl+Shift+D summons it to the " + + "mouse and adds the selection."), EditorStyles.toolbarButton, GUILayout.ExpandWidth(false)); if (now != open) TabstepShelfWindow.Toggle(this); } diff --git a/Editor/Window/TabstepShelfWindow.cs b/Editor/Window/TabstepShelfWindow.cs index 8d5b3d4..c530730 100644 --- a/Editor/Window/TabstepShelfWindow.cs +++ b/Editor/Window/TabstepShelfWindow.cs @@ -18,7 +18,7 @@ namespace Yozolab.Tabstep /// A regular floating EditorWindow, treated exactly like the Tabstep window /// itself: it moves by its own title tab, floats above the main editor window /// (standard Unity z-order for floating windows), can be docked, and survives - /// restarts through the layout. Ctrl+Shift+X — a global, rebindable shortcut, + /// restarts through the layout. Ctrl+Shift+D — a global, rebindable shortcut, /// with a fallback handler inside the Tabstep window — summons it to the mouse /// and drops the current selection onto it. /// @@ -84,12 +84,12 @@ public static TabstepShelfWindow ShowNear(EditorWindow anchor) } /// - /// The global Ctrl+Shift+X gesture: brings the shelf (existing or new) to the + /// The global Ctrl+Shift+D gesture: brings the shelf (existing or new) to the /// mouse and drops the current selection onto it. Rebindable in the Shortcut /// Manager; the Tabstep window also handles the same combination itself as a /// fallback, in case the binding is shadowed in a given setup. /// - [Shortcut("Tabstep/Summon Shelf", KeyCode.X, ShortcutModifiers.Action | ShortcutModifiers.Shift)] + [Shortcut("Tabstep/Summon Shelf", KeyCode.D, ShortcutModifiers.Action | ShortcutModifiers.Shift)] static void SummonShortcut() { SummonToMouse(); diff --git a/README.md b/README.md index 3f5b46a..8dcddae 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,11 @@ another tab's folder, an Inspector object field, the Hierarchy, the Scene view. - **Getting things onto it** — drop them on the shelf window, drop them on the `▼ Shelf` zone that appears at the right end of the tab bar while you drag assets, or use *Send Selection to Shelf* in a tab's context menu. -- **`Ctrl+Shift+X` summons it** — a global, rebindable shortcut (Shortcut - Manager: *Tabstep/Summon Shelf*) that works from any editor window: the - shelf jumps to the mouse position, and whatever assets, folders or scene - objects are selected get added to it in the same stroke. +- **`Ctrl+Shift+D` summons it** — a global, rebindable shortcut (Shortcut + Manager: *Tabstep/Summon Shelf*, with a built-in fallback while the Tabstep + window is focused): the shelf jumps to the mouse position, and whatever + assets, folders or scene objects are selected get added to it in the same + stroke. - **One-shot by default** — dragging an item out consumes it: the shelf is a hand-off, not a copy source. Dragging it back before dropping restores it; the behaviour is toggleable in preferences. @@ -127,8 +128,7 @@ Open via `YozoLab > Tabstep`. | `Ctrl+L` / `Alt+D` | Edit the path (`Tab` completes, `↑`/`↓` select, `Enter` goes, `Esc` cancels) | | `Ctrl+F` | Focus the search field (when it lives in the navigation bar) | | `Ctrl+Shift+C` | Copy the current folder's absolute path | -| `Ctrl+Shift+D` | Toggle the shelf | -| `Ctrl+Shift+X` | Summon the shelf to the mouse and add the selection (global, rebindable) | +| `Ctrl+Shift+D` | Summon the shelf to the mouse and add the selection (global, rebindable) | | Drag tab | Reorder tabs | | Middle-click tab | Close tab | | Right-click tab | Pin / quick access / shelf / close others / duplicate / copy & paste path |