This morning's fix hung on TellTarget, and TellTarget is routing state that the codebase clears deliberately. StripTellBindingOnPromote sets IsTempTab false, empties TellTarget, and keeps the name -- so a promoted tell tab is called "Player@World" permanently while carrying neither marker, and falls through both possible checks. That state survives restarts. A pinned tab whose binding did not survive a save is the same hole with a different cause; the auto-tell service logs that case as expected and repairs around it. The flag is set where the name is built from a partner and is not cleared by promotion. Renaming clears it, because at that point the user typed it. Config v26 carries it backwards for tabs that already exist: anything still holding a tell binding or the temp flag got its name from a partner. Tabs promoted before this version cannot be recovered -- nothing in the stored data says where their name came from -- and renaming one has the same effect anyway. Two more things the header was giving away. Its icon for an auto-tell tab is derived from the partner and stable across sessions, which is three bits of linkable information on a picture meant to be shareable; the message path re-salts its name hashes on every load precisely to avoid that, so screenshot mode now falls back to a plain envelope. And a world name that is not ASCII -- the CN and KR clients have those, and we ship translations for both -- was being drawn in the meta face, which carries ASCII and a middle dot. It would have come out as question marks, the same defect the split was built to prevent. Plus two that are not privacy: the header had no FontsReady gate, alone among the drawing components, so its band height and baseline offset were wrong in exactly the frames this cycle made more common. And a long tab name ran past the band and got cut mid-glyph at the window edge; it fits now, the way the honorific header already did it.
649 lines
24 KiB
C#
649 lines
24 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
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;
|
|
using HellionChat.Util;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace HellionChat;
|
|
|
|
// Auto-Tell-Tabs: spawns session-only tabs per /tell partner.
|
|
// Subscribes to MessageManager.MessageProcessed and ClientState.Logout.
|
|
internal sealed class AutoTellTabsService : IDisposable
|
|
{
|
|
private readonly Plugin _plugin;
|
|
private readonly MessageManager _messageManager;
|
|
private readonly MessageStore _store;
|
|
private readonly ILogger<AutoTellTabsService> _logger;
|
|
|
|
// Tabs-list structure lock now lives on Plugin (neutral owner) so the
|
|
// MessageManager refilter can share it. See Plugin.TabsListLock / B3.
|
|
private object TabsListLock => _plugin.TabsListLock;
|
|
|
|
// Bumped whenever something wipes unpinned temp tabs wholesale (logout).
|
|
// HandleTell reads it before releasing the lock and re-checks after, so a
|
|
// tab built in between is discarded instead of outliving the wipe.
|
|
private int _tabGeneration;
|
|
|
|
// 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;
|
|
|
|
// Set when Initialize ran before a character was available; cleared once the
|
|
// history has actually been loaded.
|
|
private bool _rehydratePending;
|
|
|
|
internal AutoTellTabsService(
|
|
Plugin plugin,
|
|
MessageManager messageManager,
|
|
MessageStore store,
|
|
ILogger<AutoTellTabsService> logger
|
|
)
|
|
{
|
|
_plugin = plugin;
|
|
_messageManager = messageManager;
|
|
_store = store;
|
|
_logger = logger;
|
|
}
|
|
|
|
// 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()
|
|
{
|
|
if (_initialized)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Pinned tabs come out of the JSON with TellTarget set but
|
|
// CurrentChannel reset (NonSerialized). Without re-seeding, the chat
|
|
// input has no tell-target on the active pinned tab, and the
|
|
// game-side channel hook only repaints CurrentChannel once the user
|
|
// triggers a /tell or channel switch.
|
|
RehydratePinnedTabs();
|
|
|
|
_messageManager.MessageProcessed += HandleTell;
|
|
Plugin.ClientState.Login += OnLogin;
|
|
Plugin.ClientState.Logout += OnLogout;
|
|
_initialized = true;
|
|
}
|
|
|
|
// Deferred when the plugin starts before a character is logged in, which is
|
|
// the normal case: the game loads plugins at boot. CurrentContentId is 0
|
|
// until then, so the history query would look up tells for character zero,
|
|
// find none, and leave every pinned tab blank for the whole session.
|
|
//
|
|
// Only visible to someone who actually pins a tell tab AND starts the game
|
|
// with the plugin already installed. Reloading the plugin in a running
|
|
// session -- what a developer does all day -- hides it completely.
|
|
private void RehydratePinnedTabs()
|
|
{
|
|
if (_messageManager.CurrentContentId == 0)
|
|
{
|
|
_logger.LogDebug("[Pin] Rehydrate deferred: no character yet, waiting for login");
|
|
_rehydratePending = true;
|
|
return;
|
|
}
|
|
|
|
_rehydratePending = false;
|
|
|
|
var pinned = Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool);
|
|
_logger.LogDebug($"[Pin] Rehydrate scan: {pinned} pinned tab(s) found");
|
|
|
|
foreach (var tab in Plugin.Config.Tabs)
|
|
{
|
|
if (!TabLifecycleHelpers.IsInPinnedPool(tab))
|
|
continue;
|
|
|
|
if (tab.TellTarget is null || !tab.TellTarget.IsSet())
|
|
{
|
|
_logger.LogWarning(
|
|
$"[Pin] Pinned tab '{tab.Name}' has no usable TellTarget "
|
|
+ $"(Name={tab.TellTarget?.Name ?? "<null>"} World={tab.TellTarget?.World ?? 0}). "
|
|
+ "Chat input on this tab will be empty until the partner sends a tell or you /tell manually."
|
|
);
|
|
continue;
|
|
}
|
|
|
|
tab.Channel ??= InputChannel.Tell;
|
|
tab.CurrentChannel.Channel = InputChannel.Tell;
|
|
tab.CurrentChannel.TellTarget = tab.TellTarget.Clone();
|
|
|
|
// MessageList is NonSerialized so pinned tabs come back empty.
|
|
// Preload the same history window the spawn path uses so the user
|
|
// sees the recent conversation, not a blank tab.
|
|
PreloadHistory(tab, tab.TellTarget.Name, tab.TellTarget.World, Guid.Empty);
|
|
|
|
_logger.LogDebug(
|
|
$"[Pin] Rehydrated '{tab.Name}' -> Tell target {tab.TellTarget.Name}@{tab.TellTarget.World}"
|
|
);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (!_initialized)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Plugin.ClientState.Login -= OnLogin;
|
|
Plugin.ClientState.Logout -= OnLogout;
|
|
_messageManager.MessageProcessed -= HandleTell;
|
|
_initialized = false;
|
|
}
|
|
|
|
internal void HandleTell(Message message)
|
|
{
|
|
if (!Plugin.Config.EnableAutoTellTabs)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (
|
|
message.Code.Type != ChatType.TellIncoming
|
|
&& message.Code.Type != ChatType.TellOutgoing
|
|
)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var partner = ExtractTellPartner(message);
|
|
if (partner == null)
|
|
{
|
|
// Diagnostics: helps detect regressions (FFXIV payload changes, new edge cases)
|
|
_logger.LogWarning(
|
|
$"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, "
|
|
+ $"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, "
|
|
+ $"senderSourcePayloads={message.SenderSource?.Payloads?.Count ?? 0}, "
|
|
+ $"contentSourcePayloads={message.ContentSource?.Payloads?.Count ?? 0}"
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Three steps, because building the tab pulls history out of the store and
|
|
// that must not happen under TabsListLock (B3 rule; the query sorts the whole
|
|
// receiver history). Step 1 and 3 are locked, step 2 is not.
|
|
int generation;
|
|
lock (TabsListLock)
|
|
{
|
|
var existing = FindTempTab(partner.Value.Name, partner.Value.World);
|
|
if (existing != null)
|
|
{
|
|
// Already routed via MessageManager pipeline — no AddMessage here,
|
|
// HandleTell runs after the delivery loop. Repair the tell-target if
|
|
// the fallback hit a pinned tab whose TellTarget didn't survive a
|
|
// previous round-trip — keeps FindTempTab fast on the next message.
|
|
if (
|
|
existing.IsPinned
|
|
&& (existing.TellTarget is null || !existing.TellTarget.IsSet())
|
|
)
|
|
{
|
|
existing.TellTarget = new TellTarget(
|
|
partner.Value.Name,
|
|
partner.Value.World,
|
|
0,
|
|
TellReason.Direct
|
|
);
|
|
_plugin.SaveConfig();
|
|
}
|
|
return;
|
|
}
|
|
|
|
generation = _tabGeneration;
|
|
}
|
|
|
|
var tab = BuildTempTabWithHistory(partner.Value, message);
|
|
|
|
lock (TabsListLock)
|
|
{
|
|
// A logout in between wiped the unpinned pool; committing now would
|
|
// resurrect a tab for a character we already left.
|
|
if (generation != _tabGeneration)
|
|
return;
|
|
|
|
// Someone else (self-test, UI) may have created the tab while we built
|
|
// ours. Hand the message to theirs and drop what we built — unlike the
|
|
// early return above, this tab appeared after the delivery loop ran.
|
|
var raced = FindTempTab(partner.Value.Name, partner.Value.World);
|
|
if (raced != null)
|
|
{
|
|
raced.AddMessage(message, unread: true);
|
|
return;
|
|
}
|
|
|
|
CommitTempTab(tab);
|
|
}
|
|
}
|
|
|
|
private (string Name, uint World)? ExtractTellPartner(Message message)
|
|
{
|
|
if (message.Code.Type == ChatType.TellIncoming)
|
|
{
|
|
// Sender is the partner; check chunks first, then raw SeString as fallback
|
|
var fromSender =
|
|
ChunkUtil.TryGetPlayerPayload(message.Sender)
|
|
?? ChunkUtil.TryGetPlayerPayload(message.SenderSource);
|
|
if (fromSender != null)
|
|
{
|
|
return (fromSender.PlayerName, fromSender.World.RowId);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Outgoing tell: check content first, then channels's TellTarget as fallback
|
|
var fromContent =
|
|
ChunkUtil.TryGetPlayerPayload(message.Content)
|
|
?? ChunkUtil.TryGetPlayerPayload(message.ContentSource)
|
|
?? ChunkUtil.TryGetPlayerPayload(message.Sender)
|
|
?? ChunkUtil.TryGetPlayerPayload(message.SenderSource);
|
|
if (fromContent != null)
|
|
{
|
|
return (fromContent.PlayerName, fromContent.World.RowId);
|
|
}
|
|
|
|
var current =
|
|
_plugin.CurrentTab.CurrentChannel.TellTarget
|
|
?? _plugin.CurrentTab.CurrentChannel.TempTellTarget;
|
|
if (current != null && current.IsSet())
|
|
{
|
|
return (current.Name, current.World);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
internal static Tab? FindTempTab(string name, uint world)
|
|
{
|
|
var byTarget = Plugin.Config.Tabs.FirstOrDefault(t =>
|
|
t.IsTempTab
|
|
&& t.TellTarget != null
|
|
&& string.Equals(t.TellTarget.Name, name, StringComparison.OrdinalIgnoreCase)
|
|
&& t.TellTarget.World == world
|
|
);
|
|
if (byTarget != null)
|
|
return byTarget;
|
|
|
|
// Fallback: match by tab name. Pinned tabs are named via
|
|
// FormatTabName(player, world) at spawn time, so the name is a
|
|
// stable secondary key when TellTarget didn't survive a save/load
|
|
// (older configs from a renamed pin, malformed migrations, etc.).
|
|
var expectedName = FormatTabName(name, world);
|
|
return Plugin.Config.Tabs.FirstOrDefault(t =>
|
|
t.IsTempTab && string.Equals(t.Name, expectedName, StringComparison.OrdinalIgnoreCase)
|
|
);
|
|
}
|
|
|
|
// Lock-protected lookup for the framework-thread caller (TellRouterService).
|
|
// Config.Tabs is mutated under the shared Plugin.TabsListLock on the worker thread,
|
|
// so a framework-tick reader must take the same lock to avoid enumerating the list
|
|
// mid-mutation.
|
|
internal Tab? FindTempTabSafe(string name, uint world)
|
|
{
|
|
lock (TabsListLock)
|
|
return FindTempTab(name, world);
|
|
}
|
|
|
|
internal void DropOldestTempTab()
|
|
{
|
|
// B3: lock the list-structure ops so the (currently caller-less) Unpin path
|
|
// can't race the worker; re-entrant when HandleTell already holds the lock.
|
|
lock (TabsListLock)
|
|
{
|
|
// 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 => TabLifecycleHelpers.IsInUnpinnedPool(t.Tab))
|
|
.OrderByDescending(t => t.Tab.IsGreeted)
|
|
.ThenBy(t => t.Tab.LastActivity)
|
|
.FirstOrDefault();
|
|
|
|
if (victim.Tab == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var dropped = victim.Tab;
|
|
// By reference, not by index: the index came from a Select() earlier in
|
|
// this block and would point at the wrong tab if anything shifted the list.
|
|
Plugin.Config.Tabs.Remove(dropped);
|
|
|
|
// Re-anchor the UI selection if it pointed at the dropped tab, and close any
|
|
// pop-out window the dropped tab owned. Both run on the PendingMessage worker
|
|
// thread and touch window state the Draw path reads (OnTabActivated re-seed +
|
|
// the pool's Unbind), so marshal onto the framework thread to serialize with
|
|
// Draw (reference_dalamud_framework_thread). TryClose is idempotent: a tab that
|
|
// was never popped is a silent no-op.
|
|
Plugin.Framework.RunOnFrameworkThread(() =>
|
|
{
|
|
_plugin.ChannelPopoutPool.TryClose(dropped.Identifier);
|
|
_plugin.MainWindow?.ResetActiveTabIfRemoved(dropped);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Runs WITHOUT TabsListLock: PreloadHistory hits the store, which used to hold
|
|
// the lock across a query that sorted the whole receiver history. The tab is not
|
|
// public until CommitTempTab adds it, so building it unlocked is safe.
|
|
private Tab BuildTempTabWithHistory((string Name, uint World) partner, Message currentMessage)
|
|
{
|
|
var tab = BuildTempTab(partner.Name, partner.World);
|
|
|
|
// Preload history: chronological order with current message already persisted
|
|
PreloadHistory(tab, partner.Name, partner.World, currentMessage.Id);
|
|
|
|
tab.AddMessage(currentMessage, unread: true);
|
|
|
|
// Flag the tab as a pop-out if configured; the marshalled TryOpen below reads
|
|
// that flag to open the real window.
|
|
if (Plugin.Config.AutoTellTabsOpenAsPopout)
|
|
{
|
|
tab.PopOut = true;
|
|
}
|
|
|
|
return tab;
|
|
}
|
|
|
|
// Caller MUST hold TabsListLock.
|
|
private void CommitTempTab(Tab tab)
|
|
{
|
|
if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
|
|
{
|
|
DropOldestTempTab();
|
|
}
|
|
|
|
Plugin.Config.Tabs.Add(tab);
|
|
|
|
// Actually open the pop-out window for the flagged tab — without this the
|
|
// flag was dead (a PopOut tab with no window). CommitTempTab runs on the
|
|
// PendingMessage worker thread under Plugin.TabsListLock; TryOpen does
|
|
// OnTabActivated + Bind (window state Draw reads), so marshal onto the
|
|
// framework thread. If the pool is full, drop the flag so it never claims a
|
|
// window it didn't get (flag/window parity).
|
|
if (tab.PopOut)
|
|
{
|
|
Plugin.Framework.RunOnFrameworkThread(() =>
|
|
{
|
|
if (!_plugin.ChannelPopoutPool.TryOpen(tab))
|
|
tab.PopOut = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
private static Tab BuildTempTab(string playerName, uint worldRowId)
|
|
{
|
|
return new Tab
|
|
{
|
|
Name = FormatTabName(playerName, worldRowId),
|
|
NameCameFromPartner = true,
|
|
IsTempTab = true,
|
|
AllSenderMessages = true,
|
|
TellTarget = new TellTarget(playerName, worldRowId, 0, TellReason.Direct),
|
|
Channel = InputChannel.Tell,
|
|
DisplayTimestamp = true,
|
|
UnreadMode = UnreadMode.Unseen,
|
|
HideWhenInactive = false,
|
|
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
|
|
{
|
|
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
|
|
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
|
|
},
|
|
};
|
|
}
|
|
|
|
private static string FormatTabName(string playerName, uint worldRowId)
|
|
{
|
|
if (Sheets.WorldSheet.TryGetRow(worldRowId, out var worldRow))
|
|
{
|
|
return $"{playerName}@{worldRow.Name}";
|
|
}
|
|
// Fallback if world lookup misses (rare; only for unseen worlds)
|
|
return $"{playerName}@World{worldRowId}";
|
|
}
|
|
|
|
private void PreloadHistory(Tab tab, string senderName, uint senderWorld, Guid currentMessageId)
|
|
{
|
|
var preloadCount = Plugin.Config.AutoTellTabsHistoryPreload;
|
|
if (preloadCount <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Pull one extra row: current message is already in store and would eat a preload slot
|
|
var history = _store.GetTellHistoryWithSender(
|
|
_messageManager.CurrentContentId,
|
|
senderName,
|
|
senderWorld,
|
|
preloadCount + 1
|
|
);
|
|
|
|
var historicMessages = history
|
|
.Where(m => m.Id != currentMessageId)
|
|
.Take(preloadCount)
|
|
.ToList();
|
|
|
|
if (historicMessages.Count == 0)
|
|
{
|
|
// No prior tells; leave tab empty to avoid orphaned "history loaded" marker
|
|
return;
|
|
}
|
|
|
|
// History is oldest-first; add in order for chronological display
|
|
foreach (var message in historicMessages)
|
|
{
|
|
tab.Messages.AddPrune(message, MessageManager.MessageDisplayLimit);
|
|
}
|
|
|
|
// Separator between history and live tell (sorts after history but before current)
|
|
tab.Messages.AddPrune(
|
|
MakeSystemMarker(HellionStrings.AutoTellTabs_HistorySeparator),
|
|
MessageManager.MessageDisplayLimit
|
|
);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Non-fatal: tab still spawns with visible error notice instead of silent history loss
|
|
_logger.LogError(ex, "[AutoTellTabs] History preload failed");
|
|
tab.Messages.AddPrune(
|
|
MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError),
|
|
MessageManager.MessageDisplayLimit
|
|
);
|
|
}
|
|
}
|
|
|
|
private static Message MakeSystemMarker(string text)
|
|
{
|
|
var seString = new SeStringBuilder().AddText(text).Build();
|
|
var chunks = ChunkUtil.ToChunks(seString, ChunkSource.Content, ChatType.System).ToList();
|
|
var code = new ChatCode((XivChatType)ChatType.System, 0, 0);
|
|
return Message.FakeMessage(chunks, code);
|
|
}
|
|
|
|
internal void MarkGreeted(Tab tab)
|
|
{
|
|
SetGreeted(tab, true);
|
|
}
|
|
|
|
internal void UnmarkGreeted(Tab tab)
|
|
{
|
|
SetGreeted(tab, false);
|
|
}
|
|
|
|
internal bool IsGreeted(Tab tab)
|
|
{
|
|
return tab.IsGreeted;
|
|
}
|
|
|
|
private void SetGreeted(Tab tab, bool greeted)
|
|
{
|
|
if (tab == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (TabsListLock)
|
|
{
|
|
// Guard against frame-race: sidebar might render a tab already removed by LRU or logout
|
|
if (!Plugin.Config.Tabs.Contains(tab))
|
|
{
|
|
return;
|
|
}
|
|
|
|
tab.IsGreeted = greeted;
|
|
}
|
|
}
|
|
|
|
// Fires on the login that follows a boot-time start, and on every character
|
|
// switch after one. Guarded by the pending flag so a switch does not append
|
|
// a second copy of the history to tabs that already have it.
|
|
private void OnLogin()
|
|
{
|
|
if (!_rehydratePending)
|
|
return;
|
|
|
|
RehydratePinnedTabs();
|
|
}
|
|
|
|
private void OnLogout(int type, int code)
|
|
{
|
|
lock (TabsListLock)
|
|
{
|
|
// Pinned TempTabs must survive char-switch — that's the whole point
|
|
// of pinning. Only unpinned ones get stripped.
|
|
var active = _plugin.MainWindow?.ActiveTab;
|
|
|
|
var poppedTempTabIds = Plugin
|
|
.Config.Tabs.Where(t =>
|
|
TabLifecycleHelpers.IsInUnpinnedPool(t)
|
|
&& _plugin.ChannelPopoutPool.IsOpen(t.Identifier)
|
|
)
|
|
.Select(t => t.Identifier)
|
|
.ToList();
|
|
|
|
// Close any pop-out window an unpinned temp tab owns before the tabs leave
|
|
// the list. Filtering on the live pool (not the PopOut flag) also catches
|
|
// manually right-clicked pop-outs, which never set the flag.
|
|
foreach (var id in poppedTempTabIds)
|
|
_plugin.ChannelPopoutPool.TryClose(id);
|
|
|
|
Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool);
|
|
|
|
// HandleTell builds a tab outside the lock; bumping here lets it detect
|
|
// that the world moved on and drop what it built. Read and compared under
|
|
// the same lock, so no volatile needed.
|
|
_tabGeneration++;
|
|
|
|
// Re-anchor the UI selection if the active tab was one of the stripped
|
|
// unpinned temp tabs (reference predicate, not an index). Logout is a
|
|
// framework-thread event, so this is already serialized with Draw — no
|
|
// marshalling needed here, unlike the worker-thread eviction path.
|
|
if (active is { } a && TabLifecycleHelpers.IsInUnpinnedPool(a))
|
|
{
|
|
_plugin.MainWindow?.ResetActiveTabIfRemoved(a);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal bool TryPin(Tab tab)
|
|
{
|
|
if (!tab.IsTempTab || tab.IsPinned)
|
|
{
|
|
_logger.LogDebug(
|
|
$"[Pin] TryPin skipped: IsTempTab={tab.IsTempTab} IsPinned={tab.IsPinned}"
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// Count and flag under one lock so the cap can't be raced. SaveConfig stays
|
|
// OUTSIDE -- holding TabsListLock across a save would put an fsync on the
|
|
// click path, which is what B6 just removed elsewhere.
|
|
lock (TabsListLock)
|
|
{
|
|
if (PinnedTempTabCount >= MaxPinnedTempTabs)
|
|
{
|
|
WrapperUtil.AddNotification(
|
|
string.Format(HellionStrings.PinTab_LimitReached, MaxPinnedTempTabs),
|
|
NotificationType.Warning
|
|
);
|
|
return false;
|
|
}
|
|
|
|
tab.IsPinned = true;
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
$"[Pin] Pinned tab '{tab.Name}' target={tab.TellTarget?.Name}@{tab.TellTarget?.World}"
|
|
);
|
|
_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. Under lock, since DropOldestTempTab mutates the list.
|
|
// SaveConfig stays outside, see TryPin.
|
|
lock (TabsListLock)
|
|
{
|
|
if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
|
|
{
|
|
DropOldestTempTab();
|
|
}
|
|
|
|
tab.IsPinned = false;
|
|
}
|
|
|
|
_logger.LogDebug("[Pin] Unpinned tab '{TabName}'", tab.Name);
|
|
_plugin.SaveConfig();
|
|
}
|
|
|
|
internal void PromoteToPermanent(Tab tab)
|
|
{
|
|
if (!tab.IsTempTab)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Drops the temp/pin flags, the persisted tell target AND the runtime
|
|
// channel's tell state. The runtime-channel clear is the CORR-1 guard —
|
|
// see StripTellBindingOnPromote; clearing Tab.TellTarget alone would leave
|
|
// CurrentChannel.Channel == Tell + a stale target and route a typed line
|
|
// silently as /tell to the old partner.
|
|
// Flips IsTempTab/IsPinned, which decide pool membership and whether a save
|
|
// strips the tab. Under lock so a concurrent save sees one or the other, never
|
|
// half. SaveConfig stays outside, see TryPin.
|
|
lock (TabsListLock)
|
|
TabLifecycleHelpers.StripTellBindingOnPromote(tab);
|
|
|
|
_logger.LogDebug($"[Pin] Promoted tab '{tab.Name}' to permanent (tell-binding dropped)");
|
|
_plugin.SaveConfig();
|
|
}
|
|
}
|