diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs index 977b77c..59413d3 100644 --- a/HellionChat/AutoTellTabsService.cs +++ b/HellionChat/AutoTellTabsService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Interface.ImGuiNotification; using HellionChat.Code; using HellionChat.GameFunctions.Types; using HellionChat.Resources; @@ -20,13 +21,11 @@ internal sealed class AutoTellTabsService : IDisposable private readonly MessageStore _store; private readonly object _tempTabsLock = new(); - // F2.1: lock-free counter mirrors Config.Tabs.Count(IsTempTab) so the - // hot-path getter doesn't contend with HandleTell on every render frame. - // Bumped from inside the existing mutation paths so it stays consistent - // with the underlying list — see SpawnTempTab, DropOldestTempTab, OnLogout - // and ResyncTempTabCounter (used by Plugin.cs snapshot-restore). - // TEST-MIRROR: ../../Hellion Build test/_Helpers/TempTabCounterTests.cs - private int _activeTempTabCount; + // Hard cap on pinned TempTabs so the sidebar doesn't inflate over years + // of usage. Separate pool from AutoTellTabsLimit (15) — pinned tabs live + // in their own bucket. A configurable cap is a vault-backlog anchor for + // a later cycle if tester feedback demands it. + internal const int MaxPinnedTempTabs = 5; private bool _initialized; @@ -37,7 +36,14 @@ internal sealed class AutoTellTabsService : IDisposable _store = store; } - internal int ActiveTempTabCount => Volatile.Read(ref _activeTempTabCount); + // Derived from the tab list on read. Pin/Unpin/Promote/Logout simply + // mutate IsPinned or remove tabs — the count adapts automatically. + // Replaces the F2.1 Interlocked counter because the new pin-state + // transitions are cold-path and don't need lock-free reads. + internal int ActiveTempTabCount => + Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInUnpinnedPool); + + internal int PinnedTempTabCount => Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool); internal void Initialize() { @@ -46,25 +52,11 @@ internal sealed class AutoTellTabsService : IDisposable return; } - // Seed the counter from the persisted Tabs list so a config that already - // contains TempTabs from a prior session starts in sync. Plugin.cs:168 - // crash-recovery has already dropped TempTabs by the time we get here, - // so the snapshot reflects post-recovery reality. - Interlocked.Exchange(ref _activeTempTabCount, Plugin.Config.Tabs.Count(t => t.IsTempTab)); - _messageManager.MessageProcessed += HandleTell; Plugin.ClientState.Logout += OnLogout; _initialized = true; } - // F2.1: callable from outside paths that mutate Config.Tabs directly - // (Plugin.cs snapshot-restore). Atomically re-pegs the counter to the - // live IsTempTab count. - internal void ResyncTempTabCounter() - { - Interlocked.Exchange(ref _activeTempTabCount, Plugin.Config.Tabs.Count(t => t.IsTempTab)); - } - public void Dispose() { if (!_initialized) @@ -170,12 +162,14 @@ internal sealed class AutoTellTabsService : IDisposable ); } - private void DropOldestTempTab() + internal void DropOldestTempTab() { - // Prioritize greeted tabs for drop; within each bucket, drop by oldest LastActivity + // Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are + // never drop candidates. They leave the bucket only via Unpin or + // PromoteToPermanent. var victim = Plugin .Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx)) - .Where(t => t.Tab.IsTempTab) + .Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t.Tab)) .OrderByDescending(t => t.Tab.IsGreeted) .ThenBy(t => t.Tab.LastActivity) .FirstOrDefault(); @@ -198,7 +192,6 @@ internal sealed class AutoTellTabsService : IDisposable } Plugin.Config.Tabs.RemoveAt(victim.Index); - Interlocked.Decrement(ref _activeTempTabCount); // Re-anchor active tab to avoid silent switch when tab is dropped if (victim.Index <= _plugin.LastTab) @@ -223,7 +216,6 @@ internal sealed class AutoTellTabsService : IDisposable } Plugin.Config.Tabs.Add(tab); - Interlocked.Increment(ref _activeTempTabCount); } private static Tab BuildTempTab(string playerName, uint worldRowId) @@ -354,14 +346,16 @@ internal sealed class AutoTellTabsService : IDisposable { lock (_tempTabsLock) { - // Snapshot active tab index before mutating list + // Pinned TempTabs must survive char-switch — that's the whole point + // of pinning. Only unpinned ones get stripped. var lastIndex = _plugin.LastTab; var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; - var currentWasTempTab = lastIndexValid && Plugin.Config.Tabs[lastIndex].IsTempTab; + var currentWasUnpinnedTempTab = + lastIndexValid + && TabLifecycleHelpers.IsInUnpinnedPool(Plugin.Config.Tabs[lastIndex]); - // Clean up pop-out windows before removing temp tabs var poppedTempTabIds = Plugin - .Config.Tabs.Where(t => t.IsTempTab && t.PopOut) + .Config.Tabs.Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t) && t.PopOut) .Select(t => t.Identifier) .ToList(); if (poppedTempTabIds.Count > 0) @@ -377,15 +371,68 @@ internal sealed class AutoTellTabsService : IDisposable } } - var removed = Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab); - Interlocked.Add(ref _activeTempTabCount, -removed); + Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool); - // Force switch to tab 0 if active tab was temp or index is now out of range + // Force switch to tab 0 if active tab was an unpinned temp tab or + // index is now out of range. Pinned tabs survive — no switch needed. var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; - if (currentWasTempTab || !stillValid) + if (currentWasUnpinnedTempTab || !stillValid) { _plugin.WantedTab = 0; } } } + + internal bool TryPin(Tab tab) + { + if (!tab.IsTempTab || tab.IsPinned) + { + return false; + } + + if (PinnedTempTabCount >= MaxPinnedTempTabs) + { + WrapperUtil.AddNotification( + string.Format(HellionStrings.PinTab_LimitReached, MaxPinnedTempTabs), + NotificationType.Warning + ); + return false; + } + + tab.IsPinned = true; + _plugin.SaveConfig(); + return true; + } + + internal void Unpin(Tab tab) + { + if (!tab.IsPinned) + { + return; + } + + // If the unpinned pool is already full, dropping the oldest before + // flipping the flag avoids counting the just-unpinned tab as a drop + // candidate. + if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit) + { + DropOldestTempTab(); + } + + tab.IsPinned = false; + _plugin.SaveConfig(); + } + + internal void PromoteToPermanent(Tab tab) + { + if (!tab.IsTempTab) + { + return; + } + + tab.IsTempTab = false; + tab.IsPinned = false; + tab.TellTarget = TellTarget.Empty(); + _plugin.SaveConfig(); + } } diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 47de533..7b4361a 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -34,7 +34,7 @@ public class ConfigKeyBind [Serializable] public class Configuration : IPluginConfiguration { - private const int LatestVersion = 16; + private const int LatestVersion = 17; public int Version { get; set; } = LatestVersion; @@ -278,16 +278,17 @@ public class Configuration : IPluginConfiguration ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton; // Keep live temp tabs alive across UpdateFrom — a settings save must - // not destroy open tell conversations. For persistent tabs, capture - // the live MessageList and LastSendUnread by Identifier before the - // replace and restore them onto the freshly cloned tabs; new tabs - // get an empty MessageList, deleted tabs lose their history (intended). - var liveTempTabs = Tabs.Where(t => t.IsTempTab).ToList(); - var livePersistentSession = Tabs.Where(t => !t.IsTempTab) + // not destroy open tell conversations. Pinned TempTabs are persistent + // and come through `other` like regular tabs; unpinned TempTabs are + // session-only and held from the local state. For persistent tabs + // (incl. pinned), capture live MessageList + LastSendUnread by + // Identifier and restore them onto the freshly cloned tabs. + var liveUnpinnedTempTabs = Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList(); + var livePersistentSession = Tabs.Where(t => !TabLifecycleHelpers.IsInUnpinnedPool(t)) .ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread)); Tabs = other - .Tabs.Where(t => !t.IsTempTab) + .Tabs.Where(t => !t.IsTempTab || t.IsPinned) .Select(t => { var clone = t.Clone(); @@ -299,7 +300,7 @@ public class Configuration : IPluginConfiguration return clone; }) .ToList(); - Tabs.AddRange(liveTempTabs); + Tabs.AddRange(liveUnpinnedTempTabs); ChatTabForward = other.ChatTabForward; ChatTabBackward = other.ChatTabBackward; @@ -404,6 +405,11 @@ public class Tab public bool HideWhenInactive; public bool IsTempTab; + + // Pinned TempTabs survive plugin reload and logout — tester feedback from + // Jin (v1.4.7). Pinned tabs live in their own pool (MaxPinnedTempTabs) + // separate from the AutoTellTabsLimit bucket. + public bool IsPinned; public bool AllSenderMessages; public TellTarget TellTarget = TellTarget.Empty(); @@ -511,6 +517,7 @@ public class Tab HideInBattle = HideInBattle, HideWhenInactive = HideWhenInactive, IsTempTab = IsTempTab, + IsPinned = IsPinned, AllSenderMessages = AllSenderMessages, TellTarget = TellTarget.Clone(), IsGreeted = IsGreeted, diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 262691d..ea1594b 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -169,20 +169,22 @@ public sealed class Plugin : IAsyncDalamudPlugin PlatformUtil = new DalamudPlatformUtil(); LogProxy = new DalamudPluginLogProxy(Log); - // Schema gate: v1.4.x requires config v16. Users on older schemas - // must install v1.4.2 first to run the migration chain. + // Schema gate: v1.4.x requires config v16+. Users on older schemas + // must install v1.4.2 first to run the migration chain. v17 adds + // Tab.IsPinned (additive, no data migration needed) so v16 configs + // load cleanly and get their Version stamp bumped after the gate. if (Config.Version < 16) { throw new InvalidOperationException( - $"HellionChat v1.4.6 requires config schema v16, got v{Config.Version}. " - + "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.6." + $"HellionChat v1.4.7 requires config schema v16, got v{Config.Version}. " + + "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.7." ); } + Config.Version = 17; - // Session-only tabs are stripped on every load; AutoTellTabsService.Initialize - // then re-pegs TempTabCounter from the stripped list, not the pre-strip snapshot. - // TEST-MIRROR: ../../Hellion Build test/_Helpers/TempTabCounterTests.cs - Config.Tabs.RemoveAll(t => t.IsTempTab); + // Unpinned TempTabs are session-only and dropped on every load. Pinned + // TempTabs survive reload — Jin's tester feedback (v1.4.7). + Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnLoad); LanguageChanged(Interface.UiLanguage); ImGuiUtil.Initialize(this); @@ -652,21 +654,17 @@ public sealed class Plugin : IAsyncDalamudPlugin internal void SaveConfig() { - // Session-only Auto-Tell-Tabs aren't persisted, so they move aside - // before serialization and re-attach after. Cloning only the temp - // subset keeps the allocation proportional to AutoTellTabsLimit - // (<=15) instead of the full tab list. - var tempTabs = Config.Tabs.Where(t => t.IsTempTab).ToList(); - Config.Tabs.RemoveAll(t => t.IsTempTab); + // Only unpinned TempTabs are session-only — they move aside before + // serialization and re-attach after. Pinned TempTabs stay in + // Config.Tabs across the save so JSON includes them. Cloning only the + // unpinned subset keeps the allocation proportional to + // AutoTellTabsLimit (<=15) instead of the full tab list. + var unpinnedTempTabs = Config.Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList(); + Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnSave); Interface.SavePluginConfig(Config); - Config.Tabs.AddRange(tempTabs); - - // F2.1: the mid-step RemoveAll bypasses AutoTellTabsService, so - // re-peg the counter. Null-conditional because SaveConfig can fire - // before Phase-2 init. - AutoTellTabsService?.ResyncTempTabCounter(); + Config.Tabs.AddRange(unpinnedTempTabs); } internal void LanguageChanged(string langCode) diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index 4ac3105..1e9e617 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -170,6 +170,12 @@ internal class HellionStrings internal static string AutoTellTabs_HistoryLoadError => Get(nameof(AutoTellTabs_HistoryLoadError)); internal static string AutoTellTabs_GreetedTooltip => Get(nameof(AutoTellTabs_GreetedTooltip)); internal static string AutoTellTabs_UnGreetedTooltip => Get(nameof(AutoTellTabs_UnGreetedTooltip)); + internal static string PinTab_MenuPin => Get(nameof(PinTab_MenuPin)); + internal static string PinTab_MenuUnpin => Get(nameof(PinTab_MenuUnpin)); + internal static string PinTab_MenuPromote => Get(nameof(PinTab_MenuPromote)); + internal static string PinTab_PromoteTooltip => Get(nameof(PinTab_PromoteTooltip)); + internal static string PinTab_LimitReached => Get(nameof(PinTab_LimitReached)); + internal static string PinTab_PinnedTooltip => Get(nameof(PinTab_PinnedTooltip)); // Hellion Chat — Auto-Tell-Tabs Chat settings tab internal static string ChatLog_AutoTellTabs_Section_Title => Get(nameof(ChatLog_AutoTellTabs_Section_Title)); diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index 687d439..602dbda 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -383,6 +383,24 @@ Als begrüßt markieren. + + Tab anpinnen + + + Tab lösen + + + Dauerhaft behalten + + + „Dauerhaft behalten" macht aus dem Tab einen regulären Tab, der zu allen channel-gefilterten Nachrichten passt — bei Bedarf danach umbenennen. + + + Maximal {0} angepinnte Tell-Tabs erreicht. Erst einen lösen oder dauerhaft behalten. + + + Angepinnt — überlebt Relog. + @@ -398,7 +416,7 @@ Maximale Anzahl der Auto-Tell-Tabs - Beim Erreichen werden begrüßte Tabs mit der ältesten Aktivität zuerst geschlossen. Änderungen greifen beim nächsten /tell. + Beim Erreichen werden begrüßte Tabs mit der ältesten Aktivität zuerst geschlossen. Änderungen greifen beim nächsten /tell. Diese Grenze gilt nur für den automatisch verwalteten Pool. Angepinnte Tell-Tabs (Rechtsklick → Tab anpinnen) leben in einem separaten Pool von bis zu 5 Tabs und überleben Relog. Kompakte Anzeige diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 0c736ea..5fb3d71 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -383,6 +383,24 @@ Mark as greeted. + + Pin Tab + + + Unpin Tab + + + Promote to permanent + + + Promote turns this into a regular tab matching all channel-filtered messages — rename afterwards if needed. + + + Maximum of {0} pinned tell tabs reached. Unpin one first, or use Promote to permanent. + + + Pinned — survives relog. + @@ -398,7 +416,7 @@ Maximum number of auto-tell tabs - When the limit is reached, greeted tabs with the oldest activity are closed first. Changes take effect on the next /tell. + When the limit is reached, greeted tabs with the oldest activity are closed first. Changes take effect on the next /tell. This limit applies to the auto-managed pool. Pinned tell tabs (right-click → Pin Tab) live in a separate pool of up to 5 and survive relog. Compact display diff --git a/HellionChat/Ui/ChatLogWindow.cs b/HellionChat/Ui/ChatLogWindow.cs index fb90cc5..e120544 100644 --- a/HellionChat/Ui/ChatLogWindow.cs +++ b/HellionChat/Ui/ChatLogWindow.cs @@ -2124,10 +2124,56 @@ public sealed class ChatLogWindow : Window anyChanged = true; } + if (tab.IsTempTab) + { + ImGui.Separator(); + DrawPinControls(tab); + } + if (anyChanged) Plugin.SaveConfig(); } + private void DrawPinControls(Tab tab) + { + var svc = Plugin.AutoTellTabsService; + if (svc == null) + return; + + if (tab.IsPinned) + { + if (ImGui.MenuItem(HellionStrings.PinTab_MenuUnpin)) + { + svc.Unpin(tab); + ImGui.CloseCurrentPopup(); + } + } + else + { + var atCap = svc.PinnedTempTabCount >= AutoTellTabsService.MaxPinnedTempTabs; + if (ImGui.MenuItem(HellionStrings.PinTab_MenuPin, enabled: !atCap)) + { + if (svc.TryPin(tab)) + ImGui.CloseCurrentPopup(); + } + if (atCap && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + ImGui.SetTooltip( + string.Format( + HellionStrings.PinTab_LimitReached, + AutoTellTabsService.MaxPinnedTempTabs + ) + ); + } + + if (ImGui.MenuItem(HellionStrings.PinTab_MenuPromote)) + { + svc.PromoteToPermanent(tab); + ImGui.CloseCurrentPopup(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(HellionStrings.PinTab_PromoteTooltip); + } + internal readonly List PopOutDocked = []; internal readonly HashSet PopOutWindows = []; diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs new file mode 100644 index 0000000..058bdfb --- /dev/null +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -0,0 +1,16 @@ +namespace HellionChat.Util; + +// Pure predicates for the TempTab pin lifecycle. Extracted from the strip +// sites in Plugin.cs and Configuration.cs so they stay in lockstep — a +// load-time strip that disagrees with the save-time strip is exactly how +// pinned tabs would silently fall out of the JSON. +internal static class TabLifecycleHelpers +{ + public static bool IsInUnpinnedPool(Tab t) => t.IsTempTab && !t.IsPinned; + + public static bool IsInPinnedPool(Tab t) => t.IsTempTab && t.IsPinned; + + public static bool ShouldStripOnLoad(Tab t) => IsInUnpinnedPool(t); + + public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t); +}