Merge branch 'feature/v1.4.7'
Security / scan (push) Successful in 23s
Build / Build (Release) (push) Successful in 30s
Forge Announce / Post changelog to Hellion Forge (push) Successful in 7s
Release / Build and attach release ZIP (push) Successful in 39s

This commit is contained in:
2026-05-13 11:07:32 +02:00
40 changed files with 907 additions and 225 deletions
+29
View File
@@ -0,0 +1,29 @@
---
subtitle: Backlog Cleanup and Mid-Features
versionsnatur: Mid-Feature-Patch
---
Achter Sub-Patch der v1.4.x Polish-Sweep-Serie. Erstes User-sichtbares Feature-Bundle seit v1.4.5 — angepinnte Tell-Tabs
die Relog überleben, opt-in Honorific-Glow, plus eine konfigurierbare Sidebar.
- **TempTell anpinnen**: Rechtsklick auf einen TempTell-Tab in der Sidebar → „Tab anpinnen". Angepinnte Tabs überleben
Plugin-Reload und Char-Logout, behalten ihre Konversations-Historie (wird beim Rehydrate aus dem MessageStore
nachgeladen) und bleiben an die gleiche /tell-Person gebunden. Hard-Cap 5 angepinnte Tabs in einem separaten Pool —
die normalen Auto-Tell-Tabs (15er Cap) sind davon entkoppelt, Gesamt-Decke 20. Die Sidebar gruppiert angepinnte Tabs
in einer eigenen „Angepinnt"-Sektion mit eigenem Trenner.
- **Honorific Glow-Outline**: rendert jetzt eine 8-Richtungs-DrawList-Outline wenn der Honorific-Titel eine Glow-Farbe
trägt. Opt-in via **Settings → Integrationen → Glow-Outline rendern (Honorific)** (Default OFF). Gradient (Color3 /
GradientColourSet / Wave / Pulse) wird geparst und im DTO weitergereicht, rendert aktuell aber statisch als
Primärfarbe — der volle Gradient-Port (Animations-Algorithmus + Pride-Palette) kommt als eigener Cycle nach.
- **Sidebar-Breite konfigurierbar**: in **Theme & Layout** ein Slider 44160 px. Default bleibt 44 px (icon-only), aber
breiter machen damit Sektion-Header wie „Aktive Tells (3)" oder „Angepinnt (2)" nicht abgeschnitten werden.
- **Settings-Save Channel-Fix**: ein Save mit aktivem Party- oder Linkshell-Tab konnte den Chat-Input zurück auf
`/tell <angepinnte Person>` springen lassen. `Configuration.UpdateFrom` bewahrt jetzt den Runtime-`CurrentChannel`
über den persistent-Tab-Merge hinweg, und `TabSwitched` deep-cloned den Seed-Channel statt sich den `UsedChannel` mit
dem vorigen Tab zu teilen.
- **Internal**: `IPluginLogProxy`-Indirektion vor Dalamud's `IPluginLog` über alle ~91 `Plugin.Log`-Call-Sites. Damit
läuft `MessageStore.Migrate0` voll-isoliert in xUnit (F12.1-Lücke aus v1.4.6 geschlossen). Plus: TempTab-Counter als
abgeleitete Property statt gecachtes Interlocked-Feld — die neuen Pin/Unpin-Übergänge sind Cold-Path, kein
Lock-Free-Vorteil mehr. Migration v16 → v17 ist rein additiv (neues `Tab.IsPinned`-Bool, Default false).
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
+162 -36
View File
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using Dalamud.Game.Text; using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Interface.ImGuiNotification;
using HellionChat.Code; using HellionChat.Code;
using HellionChat.GameFunctions.Types; using HellionChat.GameFunctions.Types;
using HellionChat.Resources; using HellionChat.Resources;
@@ -20,13 +21,11 @@ internal sealed class AutoTellTabsService : IDisposable
private readonly MessageStore _store; private readonly MessageStore _store;
private readonly object _tempTabsLock = new(); private readonly object _tempTabsLock = new();
// F2.1: lock-free counter mirrors Config.Tabs.Count(IsTempTab) so the // Hard cap on pinned TempTabs so the sidebar doesn't inflate over years
// hot-path getter doesn't contend with HandleTell on every render frame. // of usage. Separate pool from AutoTellTabsLimit (15) — pinned tabs live
// Bumped from inside the existing mutation paths so it stays consistent // in their own bucket. A configurable cap is a vault-backlog anchor for
// with the underlying list — see SpawnTempTab, DropOldestTempTab, OnLogout // a later cycle if tester feedback demands it.
// and ResyncTempTabCounter (used by Plugin.cs snapshot-restore). internal const int MaxPinnedTempTabs = 5;
// TEST-MIRROR: ../../Hellion Build test/_Helpers/TempTabCounterTests.cs
private int _activeTempTabCount;
private bool _initialized; private bool _initialized;
@@ -37,7 +36,14 @@ internal sealed class AutoTellTabsService : IDisposable
_store = store; _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() internal void Initialize()
{ {
@@ -46,23 +52,51 @@ internal sealed class AutoTellTabsService : IDisposable
return; return;
} }
// Seed the counter from the persisted Tabs list so a config that already // Pinned tabs come out of the JSON with TellTarget set but
// contains TempTabs from a prior session starts in sync. Plugin.cs:168 // CurrentChannel reset (NonSerialized). Without re-seeding, the chat
// crash-recovery has already dropped TempTabs by the time we get here, // input has no tell-target on the active pinned tab, and the
// so the snapshot reflects post-recovery reality. // game-side channel hook only repaints CurrentChannel once the user
Interlocked.Exchange(ref _activeTempTabCount, Plugin.Config.Tabs.Count(t => t.IsTempTab)); // triggers a /tell or channel switch.
RehydratePinnedTabs();
_messageManager.MessageProcessed += HandleTell; _messageManager.MessageProcessed += HandleTell;
Plugin.ClientState.Logout += OnLogout; Plugin.ClientState.Logout += OnLogout;
_initialized = true; _initialized = true;
} }
// F2.1: callable from outside paths that mutate Config.Tabs directly private void RehydratePinnedTabs()
// (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)); var pinned = Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool);
Plugin.LogProxy.Debug($"[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())
{
Plugin.LogProxy.Warning(
$"[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);
Plugin.LogProxy.Debug(
$"[Pin] Rehydrated '{tab.Name}' -> Tell target {tab.TellTarget.Name}@{tab.TellTarget.World}"
);
}
} }
public void Dispose() public void Dispose()
@@ -96,7 +130,7 @@ internal sealed class AutoTellTabsService : IDisposable
if (partner == null) if (partner == null)
{ {
// Diagnostics: helps detect regressions (FFXIV payload changes, new edge cases) // Diagnostics: helps detect regressions (FFXIV payload changes, new edge cases)
Plugin.Log.Warning( Plugin.LogProxy.Warning(
$"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, " $"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, "
+ $"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, " + $"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, "
+ $"senderSourcePayloads={message.SenderSource?.Payloads?.Count ?? 0}, " + $"senderSourcePayloads={message.SenderSource?.Payloads?.Count ?? 0}, "
@@ -110,7 +144,23 @@ internal sealed class AutoTellTabsService : IDisposable
var existing = FindTempTab(partner.Value.Name, partner.Value.World); var existing = FindTempTab(partner.Value.Name, partner.Value.World);
if (existing != null) if (existing != null)
{ {
// Already routed via MessageManager pipeline // Already routed via MessageManager pipeline. 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; return;
} }
@@ -160,22 +210,35 @@ internal sealed class AutoTellTabsService : IDisposable
return null; return null;
} }
private Tab? FindTempTab(string name, uint world) private static Tab? FindTempTab(string name, uint world)
{ {
return Plugin.Config.Tabs.FirstOrDefault(t => var byTarget = Plugin.Config.Tabs.FirstOrDefault(t =>
t.IsTempTab t.IsTempTab
&& t.TellTarget != null && t.TellTarget != null
&& string.Equals(t.TellTarget.Name, name, StringComparison.OrdinalIgnoreCase) && string.Equals(t.TellTarget.Name, name, StringComparison.OrdinalIgnoreCase)
&& t.TellTarget.World == world && 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)
);
} }
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 var victim = Plugin
.Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx)) .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) .OrderByDescending(t => t.Tab.IsGreeted)
.ThenBy(t => t.Tab.LastActivity) .ThenBy(t => t.Tab.LastActivity)
.FirstOrDefault(); .FirstOrDefault();
@@ -198,7 +261,6 @@ internal sealed class AutoTellTabsService : IDisposable
} }
Plugin.Config.Tabs.RemoveAt(victim.Index); Plugin.Config.Tabs.RemoveAt(victim.Index);
Interlocked.Decrement(ref _activeTempTabCount);
// Re-anchor active tab to avoid silent switch when tab is dropped // Re-anchor active tab to avoid silent switch when tab is dropped
if (victim.Index <= _plugin.LastTab) if (victim.Index <= _plugin.LastTab)
@@ -223,7 +285,6 @@ internal sealed class AutoTellTabsService : IDisposable
} }
Plugin.Config.Tabs.Add(tab); Plugin.Config.Tabs.Add(tab);
Interlocked.Increment(ref _activeTempTabCount);
} }
private static Tab BuildTempTab(string playerName, uint worldRowId) private static Tab BuildTempTab(string playerName, uint worldRowId)
@@ -300,7 +361,7 @@ internal sealed class AutoTellTabsService : IDisposable
catch (Exception ex) catch (Exception ex)
{ {
// Non-fatal: tab still spawns with visible error notice instead of silent history loss // Non-fatal: tab still spawns with visible error notice instead of silent history loss
Plugin.Log.Error(ex, "[AutoTellTabs] History preload failed"); Plugin.LogProxy.Error(ex, "[AutoTellTabs] History preload failed");
tab.Messages.AddPrune( tab.Messages.AddPrune(
MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError), MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError),
MessageManager.MessageDisplayLimit MessageManager.MessageDisplayLimit
@@ -354,14 +415,16 @@ internal sealed class AutoTellTabsService : IDisposable
{ {
lock (_tempTabsLock) 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 lastIndex = _plugin.LastTab;
var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; 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 var poppedTempTabIds = Plugin
.Config.Tabs.Where(t => t.IsTempTab && t.PopOut) .Config.Tabs.Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t) && t.PopOut)
.Select(t => t.Identifier) .Select(t => t.Identifier)
.ToList(); .ToList();
if (poppedTempTabIds.Count > 0) if (poppedTempTabIds.Count > 0)
@@ -377,15 +440,78 @@ internal sealed class AutoTellTabsService : IDisposable
} }
} }
var removed = Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab); Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool);
Interlocked.Add(ref _activeTempTabCount, -removed);
// 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; var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count;
if (currentWasTempTab || !stillValid) if (currentWasUnpinnedTempTab || !stillValid)
{ {
_plugin.WantedTab = 0; _plugin.WantedTab = 0;
} }
} }
} }
internal bool TryPin(Tab tab)
{
if (!tab.IsTempTab || tab.IsPinned)
{
Plugin.LogProxy.Debug(
$"[Pin] TryPin skipped: IsTempTab={tab.IsTempTab} IsPinned={tab.IsPinned}"
);
return false;
}
if (PinnedTempTabCount >= MaxPinnedTempTabs)
{
WrapperUtil.AddNotification(
string.Format(HellionStrings.PinTab_LimitReached, MaxPinnedTempTabs),
NotificationType.Warning
);
return false;
}
tab.IsPinned = true;
Plugin.LogProxy.Debug(
$"[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.
if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
{
DropOldestTempTab();
}
tab.IsPinned = false;
Plugin.LogProxy.Debug("[Pin] Unpinned tab '{tab.Name}'");
_plugin.SaveConfig();
}
internal void PromoteToPermanent(Tab tab)
{
if (!tab.IsTempTab)
{
return;
}
tab.IsTempTab = false;
tab.IsPinned = false;
tab.TellTarget = TellTarget.Empty();
Plugin.LogProxy.Debug(
$"[Pin] Promoted tab '{tab.Name}' to permanent (tell-binding dropped)"
);
_plugin.SaveConfig();
}
} }
+2 -2
View File
@@ -52,7 +52,7 @@ internal sealed class Commands : IDisposable
{ {
if (!Registered.TryGetValue(command, out var wrapper)) if (!Registered.TryGetValue(command, out var wrapper))
{ {
Plugin.Log.Warning($"Missing registration for command {command}"); Plugin.LogProxy.Warning($"Missing registration for command {command}");
return; return;
} }
@@ -62,7 +62,7 @@ internal sealed class Commands : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, $"Error while executing command {command}"); Plugin.LogProxy.Error(ex, $"Error while executing command {command}");
} }
} }
} }
+36 -11
View File
@@ -34,7 +34,7 @@ public class ConfigKeyBind
[Serializable] [Serializable]
public class Configuration : IPluginConfiguration public class Configuration : IPluginConfiguration
{ {
private const int LatestVersion = 16; private const int LatestVersion = 17;
public int Version { get; set; } = LatestVersion; public int Version { get; set; } = LatestVersion;
@@ -83,7 +83,7 @@ public class Configuration : IPluginConfiguration
// silently, like before. // silently, like before.
if (!Enum.IsDefined(typeof(ChatType), type) && _warnedUnknownChannels.Add(type)) if (!Enum.IsDefined(typeof(ChatType), type) && _warnedUnknownChannels.Add(type))
{ {
Plugin.Log.Warning( Plugin.LogProxy.Warning(
"PrivacyFilter: unrecognised ChatType {Type} — falling back to PrivacyPersistUnknownChannels={Persist}.", "PrivacyFilter: unrecognised ChatType {Type} — falling back to PrivacyPersistUnknownChannels={Persist}.",
type, type,
PrivacyPersistUnknownChannels PrivacyPersistUnknownChannels
@@ -102,10 +102,22 @@ public class Configuration : IPluginConfiguration
public bool FirstRunCompleted; public bool FirstRunCompleted;
public bool UseHellionFont = true; public bool UseHellionFont = true;
public bool ShowHonorificTitleInHeader = true; public bool ShowHonorificTitleInHeader = true;
// v1.4.7 opt-in: renders the Honorific glow outline when the title carries
// a Glow colour. Default OFF — keeps v1.4.6 visuals untouched for users
// who don't care, and dodges the per-frame DrawList overhead on low-end
// hardware. Gradient (Color3 / GradientColourSet) is parsed but rendered
// as the primary Color until a later cycle ports the animation.
public bool ShowHonorificGlow;
public bool EnableAutoTellTabs = true; public bool EnableAutoTellTabs = true;
public int AutoTellTabsLimit = 15; public int AutoTellTabsLimit = 15;
public bool AutoTellTabsCompactDisplay; public bool AutoTellTabsCompactDisplay;
public int AutoTellTabsHistoryPreload = 20; public int AutoTellTabsHistoryPreload = 20;
// Sidebar width in pixels. Default 44 mirrors the icon-only layout from
// v1.2.0; users can widen up to 160 to fit a section-header line like
// "Active Tells (3)" without truncation.
public int SidebarWidth = 44;
public bool AutoTellTabsShowGreetedToggle; public bool AutoTellTabsShowGreetedToggle;
public bool SeenPopOutInputHint; public bool SeenPopOutInputHint;
public bool PopOutInputEnabled = true; public bool PopOutInputEnabled = true;
@@ -278,16 +290,20 @@ public class Configuration : IPluginConfiguration
ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton; ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton;
// Keep live temp tabs alive across UpdateFrom — a settings save must // Keep live temp tabs alive across UpdateFrom — a settings save must
// not destroy open tell conversations. For persistent tabs, capture // not destroy open tell conversations. Pinned TempTabs are persistent
// the live MessageList and LastSendUnread by Identifier before the // and come through `other` like regular tabs; unpinned TempTabs are
// replace and restore them onto the freshly cloned tabs; new tabs // session-only and held from the local state. For persistent tabs
// get an empty MessageList, deleted tabs lose their history (intended). // (incl. pinned), capture live runtime state by Identifier and restore
var liveTempTabs = Tabs.Where(t => t.IsTempTab).ToList(); // it onto the freshly cloned tabs — CurrentChannel is critical because
var livePersistentSession = Tabs.Where(t => !t.IsTempTab) // the user may have switched channel in-game between settings-open
.ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread)); // and settings-save, and we'd otherwise overwrite that with the
// settings-time snapshot.
var liveUnpinnedTempTabs = Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
var livePersistentSession = Tabs.Where(t => !TabLifecycleHelpers.IsInUnpinnedPool(t))
.ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread, t.CurrentChannel));
Tabs = other Tabs = other
.Tabs.Where(t => !t.IsTempTab) .Tabs.Where(t => !t.IsTempTab || t.IsPinned)
.Select(t => .Select(t =>
{ {
var clone = t.Clone(); var clone = t.Clone();
@@ -295,11 +311,12 @@ public class Configuration : IPluginConfiguration
{ {
clone.Messages = live.Messages; clone.Messages = live.Messages;
clone.LastSendUnread = live.LastSendUnread; clone.LastSendUnread = live.LastSendUnread;
clone.CurrentChannel = live.CurrentChannel;
} }
return clone; return clone;
}) })
.ToList(); .ToList();
Tabs.AddRange(liveTempTabs); Tabs.AddRange(liveUnpinnedTempTabs);
ChatTabForward = other.ChatTabForward; ChatTabForward = other.ChatTabForward;
ChatTabBackward = other.ChatTabBackward; ChatTabBackward = other.ChatTabBackward;
@@ -319,6 +336,7 @@ public class Configuration : IPluginConfiguration
FirstRunCompleted = other.FirstRunCompleted; FirstRunCompleted = other.FirstRunCompleted;
UseHellionFont = other.UseHellionFont; UseHellionFont = other.UseHellionFont;
ShowHonorificTitleInHeader = other.ShowHonorificTitleInHeader; ShowHonorificTitleInHeader = other.ShowHonorificTitleInHeader;
ShowHonorificGlow = other.ShowHonorificGlow;
// v1.1.0 theme engine fields // v1.1.0 theme engine fields
Theme = other.Theme; Theme = other.Theme;
@@ -330,6 +348,7 @@ public class Configuration : IPluginConfiguration
AutoTellTabsLimit = other.AutoTellTabsLimit; AutoTellTabsLimit = other.AutoTellTabsLimit;
AutoTellTabsCompactDisplay = other.AutoTellTabsCompactDisplay; AutoTellTabsCompactDisplay = other.AutoTellTabsCompactDisplay;
AutoTellTabsHistoryPreload = other.AutoTellTabsHistoryPreload; AutoTellTabsHistoryPreload = other.AutoTellTabsHistoryPreload;
SidebarWidth = other.SidebarWidth;
AutoTellTabsShowGreetedToggle = other.AutoTellTabsShowGreetedToggle; AutoTellTabsShowGreetedToggle = other.AutoTellTabsShowGreetedToggle;
SeenPopOutInputHint = other.SeenPopOutInputHint; SeenPopOutInputHint = other.SeenPopOutInputHint;
@@ -404,6 +423,11 @@ public class Tab
public bool HideWhenInactive; public bool HideWhenInactive;
public bool IsTempTab; 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 bool AllSenderMessages;
public TellTarget TellTarget = TellTarget.Empty(); public TellTarget TellTarget = TellTarget.Empty();
@@ -511,6 +535,7 @@ public class Tab
HideInBattle = HideInBattle, HideInBattle = HideInBattle,
HideWhenInactive = HideWhenInactive, HideWhenInactive = HideWhenInactive,
IsTempTab = IsTempTab, IsTempTab = IsTempTab,
IsPinned = IsPinned,
AllSenderMessages = AllSenderMessages, AllSenderMessages = AllSenderMessages,
TellTarget = TellTarget.Clone(), TellTarget = TellTarget.Clone(),
IsGreeted = IsGreeted, IsGreeted = IsGreeted,
+8 -5
View File
@@ -101,7 +101,10 @@ public static class EmoteCache
t => t =>
{ {
if (t.IsFaulted) if (t.IsFaulted)
Plugin.Log.Error(t.Exception!, $"EmoteCache load failed for {emoteCode}"); Plugin.LogProxy.Error(
t.Exception!,
$"EmoteCache load failed for {emoteCode}"
);
}, },
TaskScheduler.Default TaskScheduler.Default
) )
@@ -158,7 +161,7 @@ public static class EmoteCache
{ {
// Reset to Unloaded so a later trigger can retry without a plugin reload. // Reset to Unloaded so a later trigger can retry without a plugin reload.
State = LoadingState.Unloaded; State = LoadingState.Unloaded;
Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized"); Plugin.LogProxy.Error(ex, "BetterTTV cache wasn't initialized");
} }
} }
@@ -214,7 +217,7 @@ public static class EmoteCache
} }
catch catch
{ {
Plugin.Log.Error("Failed to convert"); Plugin.LogProxy.Error("Failed to convert");
return null; return null;
} }
} }
@@ -304,7 +307,7 @@ public static class EmoteCache
catch (Exception ex) catch (Exception ex)
{ {
Failed = true; Failed = true;
Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}"); Plugin.LogProxy.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
} }
} }
@@ -408,7 +411,7 @@ public static class EmoteCache
catch (Exception ex) catch (Exception ex)
{ {
Failed = true; Failed = true;
Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}"); Plugin.LogProxy.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
} }
} }
} }
+2 -2
View File
@@ -58,7 +58,7 @@ public class FontManager
); );
if (stream is null) if (stream is null)
{ {
Plugin.Log.Warning( Plugin.LogProxy.Warning(
"Hellion font resource missing — falling back to system default font." "Hellion font resource missing — falling back to system default font."
); );
return null; return null;
@@ -237,7 +237,7 @@ public class FontManager
// Atlas-toolkit throws span IO and validation failures; routing the // Atlas-toolkit throws span IO and validation failures; routing the
// wider set through the fallback keeps a corrupt font config from // wider set through the fallback keeps a corrupt font config from
// taking down the whole atlas build. // taking down the whole atlas build.
Plugin.Log.Warning( Plugin.LogProxy.Warning(
e, e,
$"Configured {slot} font failed to load ({e.GetType().Name}), " $"Configured {slot} font failed to load ({e.GetType().Name}), "
+ "falling back to NotoSansCjkRegular" + "falling back to NotoSansCjkRegular"
+6 -6
View File
@@ -236,7 +236,7 @@ internal sealed unsafe class Chat : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, "Error in chat Activated event"); Plugin.LogProxy.Error(ex, "Error in chat Activated event");
} }
}); });
} }
@@ -266,7 +266,7 @@ internal sealed unsafe class Chat : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, "Error in chat Activated event"); Plugin.LogProxy.Error(ex, "Error in chat Activated event");
} }
return 1; // Prevent vanilla chat log from gaining focus return 1; // Prevent vanilla chat log from gaining focus
@@ -299,7 +299,7 @@ internal sealed unsafe class Chat : IDisposable
{ {
playerName = SeString.Parse(agent->TellPlayerName).TextValue; playerName = SeString.Parse(agent->TellPlayerName).TextValue;
worldId = agent->TellWorldId; worldId = agent->TellWorldId;
Plugin.Log.Debug($"Detected tell target '[redacted]'@{worldId}"); Plugin.LogProxy.Debug($"Detected tell target '[redacted]'@{worldId}");
} }
Plugin.CurrentTab.CurrentChannel = new UsedChannel Plugin.CurrentTab.CurrentChannel = new UsedChannel
@@ -358,7 +358,7 @@ internal sealed unsafe class Chat : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, "Error in chat Activated event"); Plugin.LogProxy.Error(ex, "Error in chat Activated event");
} }
} }
@@ -408,7 +408,7 @@ internal sealed unsafe class Chat : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, "Error in chat Activated event"); Plugin.LogProxy.Error(ex, "Error in chat Activated event");
} }
} }
@@ -624,7 +624,7 @@ internal sealed unsafe class Chat : IDisposable
if (contentId == 0) if (contentId == 0)
{ {
Plugin.ChatGui.PrintError(Language.Chat_SendTell_Error); Plugin.ChatGui.PrintError(Language.Chat_SendTell_Error);
Plugin.Log.Warning( Plugin.LogProxy.Warning(
"Tried to send a tell with ContentId being 0, sorry this is an internal error." "Tried to send a tell with ContentId being 0, sorry this is an internal error."
); );
return; return;
+2 -2
View File
@@ -215,7 +215,7 @@ internal unsafe class GameFunctions : IDisposable
} }
catch (Exception e) catch (Exception e)
{ {
Plugin.Log.Warning(e, "Unable to open adventurer plate"); Plugin.LogProxy.Warning(e, "Unable to open adventurer plate");
return false; return false;
} }
} }
@@ -255,7 +255,7 @@ internal unsafe class GameFunctions : IDisposable
var byteCount = System.Text.Encoding.UTF8.GetByteCount(ReplacementName); var byteCount = System.Text.Encoding.UTF8.GetByteCount(ReplacementName);
if (byteCount >= PlaceholderBufferSize) if (byteCount >= PlaceholderBufferSize)
{ {
Plugin.Log.Warning( Plugin.LogProxy.Warning(
$"Replacement name too long for placeholder buffer ({byteCount} bytes >= {PlaceholderBufferSize}); falling back to original." $"Replacement name too long for placeholder buffer ({byteCount} bytes >= {PlaceholderBufferSize}); falling back to original."
); );
ReplacementName = null; ReplacementName = null;
+1 -1
View File
@@ -507,7 +507,7 @@ internal unsafe class KeybindManager : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, "Error in chat Activated event"); Plugin.LogProxy.Error(ex, "Error in chat Activated event");
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Dalamud.NET.Sdk/15.0.0"> <Project Sdk="Dalamud.NET.Sdk/15.0.0">
<PropertyGroup> <PropertyGroup>
<!-- Independent versioning; see yaml changelog for upstream Chat 2 base --> <!-- Independent versioning; see yaml changelog for upstream Chat 2 base -->
<Version>1.4.6</Version> <Version>1.4.7</Version>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<!-- Use lock file to pin exact versions --> <!-- Use lock file to pin exact versions -->
+43 -15
View File
@@ -35,6 +35,49 @@ tags:
- Replacement - Replacement
- Privacy - Privacy
changelog: |- changelog: |-
**v1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)**
Eighth sub-patch of the v1.4.x polish-sweep series. First
user-visible feature bundle since v1.4.5 — pinned tell tabs that
survive relog, opt-in Honorific glow rendering, and a configurable
sidebar.
- TempTell Pin: right-click a TempTell tab in the sidebar to pin
it. Pinned tabs survive relog, keep their conversation history
(loaded on demand from the message store), and stay bound to
the same /tell partner. Hard cap of 5 pinned tabs in a pool
separate from the 15-tab auto-tell pool — total ceiling is 20
tabs. New 'Pinned' section in the sidebar with its own divider
header
- Honorific Glow outline now renders when the title carries a
Glow colour. Opt-in via Settings → Integrations → 'Render glow
outlines (Honorific)' (default off, dodges the per-frame
DrawList overhead on low-end hardware). Gradient (Color3 /
GradientColourSet / Wave / Pulse) is parsed but rendered
statically — a later cycle will port the full animation
- Sidebar width is now configurable in Theme & Layout (range
44160 px). Default stays icon-only; widen to fit section
headers like 'Active Tells (3)' without truncation
- Settings Save no longer pops the chat input back to /tell with
a pinned partner — Configuration.UpdateFrom now preserves the
runtime CurrentChannel across the persistent-tab merge, and
TabSwitched deep-clones the seeded channel instead of sharing
the previous tab's UsedChannel
- Util/ImGuiUtil.cs DrawArrows IconButton id now uses
(id + 1).ToString() instead of the operator-precedence quirk
id + 1.ToString() — generated IDs stay numerically stable
- Internal: IPluginLogProxy indirection over Dalamud's IPluginLog
routes all ~91 Plugin.Log call sites through a testable proxy.
MessageStore.Migrate0 can now run in xUnit without loading
Dalamud.dll, closing the gap F12.1 left in v1.4.6
- Internal: TempTab counter switched from an Interlocked cached
field to a derived Tabs.Count(predicate) — pin-state transitions
are cold-path and don't need lock-free reads
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
---
**v1.4.6 — Code Hygiene and Refactor (2026-05-12)** **v1.4.6 — Code Hygiene and Refactor (2026-05-12)**
Maintenance patch. No user-visible behaviour changes; tightens the Maintenance patch. No user-visible behaviour changes; tightens the
@@ -117,19 +160,4 @@ changelog: |-
--- ---
**v1.4.3 — Faster plugin load + new repo (2026-05-08)**
Heavy startup work (migrations, hooks, windows) now runs async so
Dalamud's UI stays responsive during load. Load time is comparable
to v1.4.2 — this is the foundation for v1.4.4 optimisations.
- Two-phase async load via IAsyncDalamudPlugin
- Schema-gate replaces the v9→v16 migration chain; old configs
require a v1.4.2 install first
- AutoTranslate cache loads on first use instead of every startup
- Custom font (Hellion-Exo2) appears with a brief pop after load
- Repo moved to gitea.hellion-forge.cloud — update your custom-repo URL
---
Full history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases Full history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases
+11 -2
View File
@@ -4,10 +4,19 @@ namespace HellionChat.Integrations;
// Local DTO mirroring Honorific's TitleData — no hard reference to Honorific.dll // Local DTO mirroring Honorific's TitleData — no hard reference to Honorific.dll
// so HellionChat loads cleanly when Honorific is absent. // so HellionChat loads cleanly when Honorific is absent.
// Glow/gradient fields omitted; Cycle 1 renders primary Color only. //
// v1.4.7: render Glow only. Gradient (Color3 / GradientColourSet / Style) is
// parsed and stashed so a future cycle can render it without re-shaping the
// JSON roundtrip — see vault anchor "Honorific Full Gradient Port" (would
// need GradientSystem.cs + the hardcoded Pride-palette list ported, or an
// upstream IPC PR exposing the resolved frame colour).
internal sealed record HonorificTitleData( internal sealed record HonorificTitleData(
string? Title, string? Title,
bool IsPrefix, bool IsPrefix,
bool IsOriginal, bool IsOriginal,
Vector3? Color Vector3? Color,
Vector3? Glow,
Vector3? Color3,
int? GradientColourSet,
string? GradientAnimationStyle
); );
+4 -1
View File
@@ -62,7 +62,10 @@ public sealed class ExtraChat : IDisposable
catch (Exception ex) catch (Exception ex)
{ {
// ExtraChat is optional; IPC failure is normal when the plugin isn't loaded. // ExtraChat is optional; IPC failure is normal when the plugin isn't loaded.
Plugin.Log.Verbose(ex, "ExtraChat IPC initial state query failed (peer not loaded?)"); Plugin.LogProxy.Verbose(
ex,
"ExtraChat IPC initial state query failed (peer not loaded?)"
);
} }
} }
+4 -4
View File
@@ -153,8 +153,8 @@ public partial class Message
} }
catch (ArgumentException ex) catch (ArgumentException ex)
{ {
Plugin.Log.Error(ex, "Failed to parse extra chat channel GUID"); Plugin.LogProxy.Error(ex, "Failed to parse extra chat channel GUID");
Plugin.Log.Error($"Byte Array: ${string.Join(", ", data[4..^1])}"); Plugin.LogProxy.Error($"Byte Array: ${string.Join(", ", data[4..^1])}");
return Guid.Empty; return Guid.Empty;
} }
} }
@@ -251,7 +251,7 @@ public partial class Message
AddChunkWithMessage( AddChunkWithMessage(
text.NewWithStyle(chunk.Source, chunk.Link, token.Value) text.NewWithStyle(chunk.Source, chunk.Link, token.Value)
); );
Plugin.Log.Debug( Plugin.LogProxy.Debug(
$"Invalid URL accepted by Regex but failed URI parsing: '{token.Value}'" $"Invalid URL accepted by Regex but failed URI parsing: '{token.Value}'"
); );
} }
@@ -416,7 +416,7 @@ public partial class Message
catch (Exception) catch (Exception)
{ {
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split)); AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
Plugin.Log.Debug($"Failed to parse the text param: '{split}'"); Plugin.LogProxy.Debug($"Failed to parse the text param: '{split}'");
} }
} }
} }
+10 -8
View File
@@ -52,7 +52,7 @@ internal class MessageManager : IAsyncDisposable
{ {
Plugin = plugin; Plugin = plugin;
Store = new MessageStore(DatabasePath(), Plugin.PlatformUtil); Store = new MessageStore(DatabasePath(), Plugin.PlatformUtil, Plugin.LogProxy);
PendingMessageThread = new Thread(() => PendingMessageThread = new Thread(() =>
ProcessPendingMessages(PendingThreadCancellationToken.Token) ProcessPendingMessages(PendingThreadCancellationToken.Token)
@@ -91,7 +91,7 @@ internal class MessageManager : IAsyncDisposable
await Task.Delay(100); await Task.Delay(100);
if (PendingMessageThread.IsAlive) if (PendingMessageThread.IsAlive)
Plugin.Log.Warning( Plugin.LogProxy.Warning(
"PendingMessageThread did not observe cancellation within 10s. " "PendingMessageThread did not observe cancellation within 10s. "
+ "Worker remains on background thread; next plugin reload releases it." + "Worker remains on background thread; next plugin reload releases it."
); );
@@ -137,7 +137,7 @@ internal class MessageManager : IAsyncDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, "Error processing pending message"); Plugin.LogProxy.Error(ex, "Error processing pending message");
} }
} }
else else
@@ -182,10 +182,12 @@ internal class MessageManager : IAsyncDisposable
// Mark failed messages as deleted to prevent retry attempts // Mark failed messages as deleted to prevent retry attempts
var failedIds = messages.FailedMessageIds(); var failedIds = messages.FailedMessageIds();
Plugin.Log.Info($"Marking {failedIds.Count} messages as deleted due to parse failures"); Plugin.LogProxy.Info(
$"Marking {failedIds.Count} messages as deleted due to parse failures"
);
foreach (var msgId in messages.FailedMessageIds()) foreach (var msgId in messages.FailedMessageIds())
{ {
Plugin.Log.Debug($"Marking message '{msgId}' as deleted due to parse failure"); Plugin.LogProxy.Debug($"Marking message '{msgId}' as deleted due to parse failure");
Store.DeleteMessage(msgId); Store.DeleteMessage(msgId);
} }
} }
@@ -201,10 +203,10 @@ internal class MessageManager : IAsyncDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, "Error in FilterAllTabs"); Plugin.LogProxy.Error(ex, "Error in FilterAllTabs");
} }
Plugin.Log.Debug($"FilterAllTabs took {stopwatch.ElapsedMilliseconds}ms"); Plugin.LogProxy.Debug($"FilterAllTabs took {stopwatch.ElapsedMilliseconds}ms");
}); });
} }
@@ -259,7 +261,7 @@ internal class MessageManager : IAsyncDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, "Error in ContentIdResolver"); Plugin.LogProxy.Error(ex, "Error in ContentIdResolver");
} }
} }
+19 -18
View File
@@ -137,11 +137,13 @@ internal class MessageStore : IDisposable
); );
private readonly IPlatformUtil _platformUtil; private readonly IPlatformUtil _platformUtil;
private readonly IPluginLogProxy _logger;
internal MessageStore(string dbPath, IPlatformUtil platformUtil) internal MessageStore(string dbPath, IPlatformUtil platformUtil, IPluginLogProxy logger)
{ {
DbPath = dbPath; DbPath = dbPath;
_platformUtil = platformUtil; _platformUtil = platformUtil;
_logger = logger;
Connection = Connect(); Connection = Connect();
Migrate(); Migrate();
} }
@@ -204,7 +206,7 @@ internal class MessageStore : IDisposable
private void Migrate0() private void Migrate0()
{ {
Plugin.Log.Information("Running migration 0: Creating tables"); _logger.Information("Running migration 0: Creating tables");
Connection.Execute( Connection.Execute(
@" @"
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
@@ -231,7 +233,7 @@ internal class MessageStore : IDisposable
private void Migrate1() private void Migrate1()
{ {
Plugin.Log.Information("Running migration 1: Adding Deleted column"); _logger.Information("Running migration 1: Adding Deleted column");
Connection.Execute( Connection.Execute(
@" @"
ALTER TABLE messages ADD COLUMN Deleted BOOLEAN NOT NULL DEFAULT false; ALTER TABLE messages ADD COLUMN Deleted BOOLEAN NOT NULL DEFAULT false;
@@ -243,7 +245,7 @@ internal class MessageStore : IDisposable
private void Migrate2() private void Migrate2()
{ {
Plugin.Log.Information("Running migration 2: Adding Channel generated column"); _logger.Information("Running migration 2: Adding Channel generated column");
Connection.Execute( Connection.Execute(
@" @"
ALTER TABLE messages ADD COLUMN Channel INTEGER GENERATED ALWAYS AS (Code & 0x7f) VIRTUAL; ALTER TABLE messages ADD COLUMN Channel INTEGER GENERATED ALWAYS AS (Code & 0x7f) VIRTUAL;
@@ -271,15 +273,13 @@ internal class MessageStore : IDisposable
private void Migrate3() private void Migrate3()
{ {
Plugin.Log.Information("Running migration 3: Fix log kinds to fit the new format"); _logger.Information("Running migration 3: Fix log kinds to fit the new format");
// Recovery for partially-applied Migrate3: schema already in target // Recovery for partially-applied Migrate3: schema already in target
// shape but user_version was never bumped -- just record and exit. // shape but user_version was never bumped -- just record and exit.
if (ColumnExists("messages", "ChatType") && !ColumnExists("messages", "Code")) if (ColumnExists("messages", "ChatType") && !ColumnExists("messages", "Code"))
{ {
Plugin.Log.Information( _logger.Information("Migration 3: schema already migrated, only bumping user_version");
"Migration 3: schema already migrated, only bumping user_version"
);
SetMigrationVersion(3); SetMigrationVersion(3);
return; return;
} }
@@ -309,7 +309,7 @@ internal class MessageStore : IDisposable
private void SetMigrationVersion(int version) private void SetMigrationVersion(int version)
{ {
Plugin.Log.Information($"Setting version {version}"); _logger.Information($"Setting version {version}");
using var cmd = Connection.CreateCommand(); using var cmd = Connection.CreateCommand();
// PRAGMA does not accept SQLite parameter bindings; version is a // PRAGMA does not accept SQLite parameter bindings; version is a
// compile-time int from the migration sequence, never user input. // compile-time int from the migration sequence, never user input.
@@ -461,7 +461,7 @@ internal class MessageStore : IDisposable
// Privacy filter -- drop disallowed ChatTypes before they reach storage. // Privacy filter -- drop disallowed ChatTypes before they reach storage.
if (!Plugin.Config.IsAllowedForStorage(message.Code.Type)) if (!Plugin.Config.IsAllowedForStorage(message.Code.Type))
{ {
Plugin.Log.Verbose($"Privacy filter dropped message: ChatType={message.Code.Type}"); _logger.Verbose($"Privacy filter dropped message: ChatType={message.Code.Type}");
return; return;
} }
@@ -554,7 +554,7 @@ internal class MessageStore : IDisposable
if (to is not null) if (to is not null)
cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds());
return new MessageEnumerator(cmd.ExecuteReader()); return new MessageEnumerator(cmd.ExecuteReader(), _logger);
} }
// Returns the most recent messages, oldest-first. // Returns the most recent messages, oldest-first.
@@ -602,7 +602,7 @@ internal class MessageStore : IDisposable
cmd.Parameters.AddWithValue("$Count", count); cmd.Parameters.AddWithValue("$Count", count);
return new MessageEnumerator(cmd.ExecuteReader()); return new MessageEnumerator(cmd.ExecuteReader(), _logger);
} }
// Returns up to limit tells exchanged with the named player, oldest-first. // Returns up to limit tells exchanged with the named player, oldest-first.
@@ -640,7 +640,7 @@ internal class MessageStore : IDisposable
cmd.Parameters.AddWithValue("$ScanLimit", sqlScanLimit); cmd.Parameters.AddWithValue("$ScanLimit", sqlScanLimit);
var collected = new List<Message>(); var collected = new List<Message>();
using var enumerator = new MessageEnumerator(cmd.ExecuteReader()); using var enumerator = new MessageEnumerator(cmd.ExecuteReader(), _logger);
foreach (var message in enumerator) foreach (var message in enumerator)
{ {
if (!ChunkUtil.MatchesSender(message, senderName, senderWorld)) if (!ChunkUtil.MatchesSender(message, senderName, senderWorld))
@@ -732,7 +732,7 @@ internal class MessageStore : IDisposable
cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds());
return new MessageEnumerator(cmd.ExecuteReader()); return new MessageEnumerator(cmd.ExecuteReader(), _logger);
} }
internal MessageEnumerator GetPagedDateRange( internal MessageEnumerator GetPagedDateRange(
@@ -776,7 +776,7 @@ internal class MessageStore : IDisposable
cmd.Parameters.AddWithValue("$Offset", DbViewer.RowPerPage * page); cmd.Parameters.AddWithValue("$Offset", DbViewer.RowPerPage * page);
cmd.Parameters.AddWithValue("$OffsetCount", DbViewer.RowPerPage); cmd.Parameters.AddWithValue("$OffsetCount", DbViewer.RowPerPage);
return new MessageEnumerator(cmd.ExecuteReader()); return new MessageEnumerator(cmd.ExecuteReader(), _logger);
} }
// Builds a "$prefix0,$prefix1,..." placeholder list and binds values to the command. // Builds a "$prefix0,$prefix1,..." placeholder list and binds values to the command.
@@ -796,13 +796,14 @@ internal class MessageStore : IDisposable
} }
} }
internal class MessageEnumerator(DbDataReader reader) internal class MessageEnumerator(DbDataReader reader, IPluginLogProxy logger)
: IEnumerable<Message>, : IEnumerable<Message>,
IDisposable, IDisposable,
IAsyncDisposable IAsyncDisposable
{ {
private const int MaxErrorLogs = 10; private const int MaxErrorLogs = 10;
private readonly IPluginLogProxy _logger = logger;
private readonly List<Guid> FailedIds = []; private readonly List<Guid> FailedIds = [];
private int FailedCount; private int FailedCount;
public bool DidError => FailedCount > 0; public bool DidError => FailedCount > 0;
@@ -848,10 +849,10 @@ internal class MessageEnumerator(DbDataReader reader)
catch (Exception e) catch (Exception e)
{ {
if (FailedCount < MaxErrorLogs) if (FailedCount < MaxErrorLogs)
Plugin.Log.Error($"Exception while reading message '{id}' from database: {e}"); _logger.Error($"Exception while reading message '{id}' from database: {e}");
FailedCount++; FailedCount++;
if (FailedCount == MaxErrorLogs) if (FailedCount == MaxErrorLogs)
Plugin.Log.Error("Further parsing errors will not be logged"); _logger.Error("Further parsing errors will not be logged");
if (id != Guid.Empty) if (id != Guid.Empty)
FailedIds.Add(id); FailedIds.Add(id);
+3 -3
View File
@@ -131,7 +131,7 @@ public sealed class PayloadHandler
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, "Error executing integration"); Plugin.LogProxy.Error(ex, "Error executing integration");
} }
} }
@@ -535,7 +535,7 @@ public sealed class PayloadHandler
) )
) )
{ {
Plugin.Log.Warning("Could not find DalamudLinkHandlers"); Plugin.LogProxy.Warning("Could not find DalamudLinkHandlers");
return; return;
} }
@@ -546,7 +546,7 @@ public sealed class PayloadHandler
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, "Error executing DalamudLinkPayload handler"); Plugin.LogProxy.Error(ex, "Error executing DalamudLinkPayload handler");
} }
} }
+24 -20
View File
@@ -117,6 +117,11 @@ public sealed class Plugin : IAsyncDalamudPlugin
// any service allocated in LoadAsync can read Plugin.PlatformUtil. // any service allocated in LoadAsync can read Plugin.PlatformUtil.
internal static IPlatformUtil PlatformUtil { get; private set; } = null!; internal static IPlatformUtil PlatformUtil { get; private set; } = null!;
// Log indirection over Dalamud's IPluginLog. Same rationale as PlatformUtil:
// call-sites read through LogProxy so MessageStore can be tested in
// isolation. Wired immediately after Dalamud injects Log.
internal static IPluginLogProxy LogProxy { get; private set; } = null!;
// Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race. // Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race.
private int _disposeStarted; private int _disposeStarted;
@@ -162,21 +167,24 @@ public sealed class Plugin : IAsyncDalamudPlugin
// needs Util.* — services then read Plugin.PlatformUtil instead of // needs Util.* — services then read Plugin.PlatformUtil instead of
// hitting the Dalamud static surface directly. // hitting the Dalamud static surface directly.
PlatformUtil = new DalamudPlatformUtil(); PlatformUtil = new DalamudPlatformUtil();
LogProxy = new DalamudPluginLogProxy(Log);
// Schema gate: v1.4.x requires config v16. Users on older schemas // Schema gate: v1.4.x requires config v16+. Users on older schemas
// must install v1.4.2 first to run the migration chain. // 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) if (Config.Version < 16)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"HellionChat v1.4.6 requires config schema v16, got v{Config.Version}. " $"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.6." + "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 // Unpinned TempTabs are session-only and dropped on every load. Pinned
// then re-pegs TempTabCounter from the stripped list, not the pre-strip snapshot. // TempTabs survive reload — Jin's tester feedback (v1.4.7).
// TEST-MIRROR: ../../Hellion Build test/_Helpers/TempTabCounterTests.cs Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnLoad);
Config.Tabs.RemoveAll(t => t.IsTempTab);
LanguageChanged(Interface.UiLanguage); LanguageChanged(Interface.UiLanguage);
ImGuiUtil.Initialize(this); ImGuiUtil.Initialize(this);
@@ -646,21 +654,17 @@ public sealed class Plugin : IAsyncDalamudPlugin
internal void SaveConfig() internal void SaveConfig()
{ {
// Session-only Auto-Tell-Tabs aren't persisted, so they move aside // Only unpinned TempTabs are session-only — they move aside before
// before serialization and re-attach after. Cloning only the temp // serialization and re-attach after. Pinned TempTabs stay in
// subset keeps the allocation proportional to AutoTellTabsLimit // Config.Tabs across the save so JSON includes them. Cloning only the
// (<=15) instead of the full tab list. // unpinned subset keeps the allocation proportional to
var tempTabs = Config.Tabs.Where(t => t.IsTempTab).ToList(); // AutoTellTabsLimit (<=15) instead of the full tab list.
Config.Tabs.RemoveAll(t => t.IsTempTab); var unpinnedTempTabs = Config.Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnSave);
Interface.SavePluginConfig(Config); Interface.SavePluginConfig(Config);
Config.Tabs.AddRange(tempTabs); Config.Tabs.AddRange(unpinnedTempTabs);
// 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();
} }
internal void LanguageChanged(string langCode) internal void LanguageChanged(string langCode)
+12
View File
@@ -170,6 +170,16 @@ internal class HellionStrings
internal static string AutoTellTabs_HistoryLoadError => Get(nameof(AutoTellTabs_HistoryLoadError)); internal static string AutoTellTabs_HistoryLoadError => Get(nameof(AutoTellTabs_HistoryLoadError));
internal static string AutoTellTabs_GreetedTooltip => Get(nameof(AutoTellTabs_GreetedTooltip)); internal static string AutoTellTabs_GreetedTooltip => Get(nameof(AutoTellTabs_GreetedTooltip));
internal static string AutoTellTabs_UnGreetedTooltip => Get(nameof(AutoTellTabs_UnGreetedTooltip)); 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));
internal static string PinTab_PinTooltip => Get(nameof(PinTab_PinTooltip));
internal static string PinTab_SectionHeader => Get(nameof(PinTab_SectionHeader));
internal static string Settings_ThemeAndLayout_SidebarWidth_Name => Get(nameof(Settings_ThemeAndLayout_SidebarWidth_Name));
internal static string Settings_ThemeAndLayout_SidebarWidth_Description => Get(nameof(Settings_ThemeAndLayout_SidebarWidth_Description));
// Hellion Chat — Auto-Tell-Tabs Chat settings tab // Hellion Chat — Auto-Tell-Tabs Chat settings tab
internal static string ChatLog_AutoTellTabs_Section_Title => Get(nameof(ChatLog_AutoTellTabs_Section_Title)); internal static string ChatLog_AutoTellTabs_Section_Title => Get(nameof(ChatLog_AutoTellTabs_Section_Title));
@@ -370,6 +380,8 @@ internal class HellionStrings
internal static string Settings_Integrations_Honorific_Status_Incompatible => Get(nameof(Settings_Integrations_Honorific_Status_Incompatible)); internal static string Settings_Integrations_Honorific_Status_Incompatible => Get(nameof(Settings_Integrations_Honorific_Status_Incompatible));
internal static string Settings_Integrations_Honorific_Toggle => Get(nameof(Settings_Integrations_Honorific_Toggle)); internal static string Settings_Integrations_Honorific_Toggle => Get(nameof(Settings_Integrations_Honorific_Toggle));
internal static string Settings_Integrations_Honorific_ToggleHint => Get(nameof(Settings_Integrations_Honorific_ToggleHint)); internal static string Settings_Integrations_Honorific_ToggleHint => Get(nameof(Settings_Integrations_Honorific_ToggleHint));
internal static string Settings_Integrations_Honorific_Glow_Toggle => Get(nameof(Settings_Integrations_Honorific_Glow_Toggle));
internal static string Settings_Integrations_Honorific_Glow_Hint => Get(nameof(Settings_Integrations_Honorific_Glow_Hint));
internal static string Settings_Integrations_Honorific_LinkRepo => Get(nameof(Settings_Integrations_Honorific_LinkRepo)); internal static string Settings_Integrations_Honorific_LinkRepo => Get(nameof(Settings_Integrations_Honorific_LinkRepo));
internal static string Settings_Integrations_Honorific_LinkAuthor => Get(nameof(Settings_Integrations_Honorific_LinkAuthor)); internal static string Settings_Integrations_Honorific_LinkAuthor => Get(nameof(Settings_Integrations_Honorific_LinkAuthor));
internal static string Settings_Integrations_ComingSoon_SectionHeader => Get(nameof(Settings_Integrations_ComingSoon_SectionHeader)); internal static string Settings_Integrations_ComingSoon_SectionHeader => Get(nameof(Settings_Integrations_ComingSoon_SectionHeader));
+37 -1
View File
@@ -383,6 +383,36 @@
<data name="AutoTellTabs_UnGreetedTooltip" xml:space="preserve"> <data name="AutoTellTabs_UnGreetedTooltip" xml:space="preserve">
<value>Als begrüßt markieren.</value> <value>Als begrüßt markieren.</value>
</data> </data>
<data name="PinTab_MenuPin" xml:space="preserve">
<value>Tab anpinnen</value>
</data>
<data name="PinTab_MenuUnpin" xml:space="preserve">
<value>Tab lösen</value>
</data>
<data name="PinTab_MenuPromote" xml:space="preserve">
<value>In Standard-Tab umwandeln</value>
</data>
<data name="PinTab_PromoteTooltip" xml:space="preserve">
<value>Wandelt den TempTell in einen regulären Tab um. Die Tell-Bindung an die Person geht verloren, der Tab fängt dann Nachrichten anhand der Channel-Filter ein. Für „Tab überlebt Relog" stattdessen „Tab anpinnen" wählen.</value>
</data>
<data name="PinTab_LimitReached" xml:space="preserve">
<value>Maximal {0} angepinnte Tell-Tabs erreicht. Erst einen lösen oder dauerhaft behalten.</value>
</data>
<data name="PinTab_PinnedTooltip" xml:space="preserve">
<value>Angepinnt — überlebt Relog.</value>
</data>
<data name="PinTab_PinTooltip" xml:space="preserve">
<value>Angepinnte Tabs überleben Relog und behalten die Bindung an die Tell-Person.</value>
</data>
<data name="PinTab_SectionHeader" xml:space="preserve">
<value>Angepinnt</value>
</data>
<data name="Settings_ThemeAndLayout_SidebarWidth_Name" xml:space="preserve">
<value>Sidebar-Breite</value>
</data>
<data name="Settings_ThemeAndLayout_SidebarWidth_Description" xml:space="preserve">
<value>Breite der Tab-Sidebar in Pixeln. Default (44 px) ist Icon-only; breiter machen damit Sektion-Header wie „Aktive Tells (3)" nicht abgeschnitten werden.</value>
</data>
<!-- Hellion Chat — Auto-Tell-Tabs (Chat-Einstellungstab) --> <!-- Hellion Chat — Auto-Tell-Tabs (Chat-Einstellungstab) -->
<data name="ChatLog_AutoTellTabs_Section_Title" xml:space="preserve"> <data name="ChatLog_AutoTellTabs_Section_Title" xml:space="preserve">
@@ -398,7 +428,7 @@
<value>Maximale Anzahl der Auto-Tell-Tabs</value> <value>Maximale Anzahl der Auto-Tell-Tabs</value>
</data> </data>
<data name="ChatLog_AutoTellTabs_Limit_Description" xml:space="preserve"> <data name="ChatLog_AutoTellTabs_Limit_Description" xml:space="preserve">
<value>Beim Erreichen werden begrüßte Tabs mit der ältesten Aktivität zuerst geschlossen. Änderungen greifen beim nächsten /tell.</value> <value>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.</value>
</data> </data>
<data name="ChatLog_AutoTellTabs_Compact_Name" xml:space="preserve"> <data name="ChatLog_AutoTellTabs_Compact_Name" xml:space="preserve">
<value>Kompakte Anzeige</value> <value>Kompakte Anzeige</value>
@@ -827,6 +857,12 @@
<data name="Settings_Integrations_Honorific_ToggleHint" xml:space="preserve"> <data name="Settings_Integrations_Honorific_ToggleHint" xml:space="preserve">
<value>Zeigt deinen Custom-Titel aus Honorific im Header über dem Chat-Log an, in der von dir gewählten Farbe.</value> <value>Zeigt deinen Custom-Titel aus Honorific im Header über dem Chat-Log an, in der von dir gewählten Farbe.</value>
</data> </data>
<data name="Settings_Integrations_Honorific_Glow_Toggle" xml:space="preserve">
<value>Glow-Outline rendern (Honorific)</value>
</data>
<data name="Settings_Integrations_Honorific_Glow_Hint" xml:space="preserve">
<value>Kann die Framerate auf schwacher Hardware drücken. Rendert die Glow-Outline für Honorific-Titel, die sie nutzen. Gradient-Animation wird noch nicht unterstützt und wird stattdessen als Primärfarbe gezeichnet.</value>
</data>
<data name="Settings_Integrations_Honorific_LinkRepo" xml:space="preserve"> <data name="Settings_Integrations_Honorific_LinkRepo" xml:space="preserve">
<value>Honorific auf GitHub</value> <value>Honorific auf GitHub</value>
</data> </data>
+37 -1
View File
@@ -383,6 +383,36 @@
<data name="AutoTellTabs_UnGreetedTooltip" xml:space="preserve"> <data name="AutoTellTabs_UnGreetedTooltip" xml:space="preserve">
<value>Mark as greeted.</value> <value>Mark as greeted.</value>
</data> </data>
<data name="PinTab_MenuPin" xml:space="preserve">
<value>Pin Tab</value>
</data>
<data name="PinTab_MenuUnpin" xml:space="preserve">
<value>Unpin Tab</value>
</data>
<data name="PinTab_MenuPromote" xml:space="preserve">
<value>Promote to permanent</value>
</data>
<data name="PinTab_PromoteTooltip" xml:space="preserve">
<value>Turns this TempTell into a regular tab. The tell binding to the partner is dropped — the tab will catch messages by its channel filters from now on. For "tab survives relog while staying bound to this partner", use Pin Tab instead.</value>
</data>
<data name="PinTab_PinTooltip" xml:space="preserve">
<value>Pinned tabs survive relog and stay bound to this conversation partner.</value>
</data>
<data name="PinTab_SectionHeader" xml:space="preserve">
<value>Pinned</value>
</data>
<data name="Settings_ThemeAndLayout_SidebarWidth_Name" xml:space="preserve">
<value>Sidebar width</value>
</data>
<data name="Settings_ThemeAndLayout_SidebarWidth_Description" xml:space="preserve">
<value>Width of the tab sidebar in pixels. The default (44 px) is icon-only; widen it to fit the section headers like "Active Tells (3)" without truncation.</value>
</data>
<data name="PinTab_LimitReached" xml:space="preserve">
<value>Maximum of {0} pinned tell tabs reached. Unpin one first, or use Promote to permanent.</value>
</data>
<data name="PinTab_PinnedTooltip" xml:space="preserve">
<value>Pinned — survives relog.</value>
</data>
<!-- Hellion Chat — Auto-Tell-Tabs (Chat settings tab) --> <!-- Hellion Chat — Auto-Tell-Tabs (Chat settings tab) -->
<data name="ChatLog_AutoTellTabs_Section_Title" xml:space="preserve"> <data name="ChatLog_AutoTellTabs_Section_Title" xml:space="preserve">
@@ -398,7 +428,7 @@
<value>Maximum number of auto-tell tabs</value> <value>Maximum number of auto-tell tabs</value>
</data> </data>
<data name="ChatLog_AutoTellTabs_Limit_Description" xml:space="preserve"> <data name="ChatLog_AutoTellTabs_Limit_Description" xml:space="preserve">
<value>When the limit is reached, greeted tabs with the oldest activity are closed first. Changes take effect on the next /tell.</value> <value>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.</value>
</data> </data>
<data name="ChatLog_AutoTellTabs_Compact_Name" xml:space="preserve"> <data name="ChatLog_AutoTellTabs_Compact_Name" xml:space="preserve">
<value>Compact display</value> <value>Compact display</value>
@@ -827,6 +857,12 @@
<data name="Settings_Integrations_Honorific_ToggleHint" xml:space="preserve"> <data name="Settings_Integrations_Honorific_ToggleHint" xml:space="preserve">
<value>Shows your custom title from Honorific in the header above the chat log, in the colour you have chosen.</value> <value>Shows your custom title from Honorific in the header above the chat log, in the colour you have chosen.</value>
</data> </data>
<data name="Settings_Integrations_Honorific_Glow_Toggle" xml:space="preserve">
<value>Render glow outlines (Honorific)</value>
</data>
<data name="Settings_Integrations_Honorific_Glow_Hint" xml:space="preserve">
<value>May reduce frame rate on low-end hardware. Renders glow outlines for Honorific titles that use them. Gradient animation is not yet supported and will render as the primary colour.</value>
</data>
<data name="Settings_Integrations_Honorific_LinkRepo" xml:space="preserve"> <data name="Settings_Integrations_Honorific_LinkRepo" xml:space="preserve">
<value>Honorific on GitHub</value> <value>Honorific on GitHub</value>
</data> </data>
+1 -1
View File
@@ -118,7 +118,7 @@ public sealed class ThemeRegistry
catch (Exception ex) when (IsRecoverableFileLock(ex)) catch (Exception ex) when (IsRecoverableFileLock(ex))
{ {
// Editor mid-save: keep last known good, retry on next refresh. // Editor mid-save: keep last known good, retry on next refresh.
Plugin.Log.Debug( Plugin.LogProxy.Debug(
$"Custom theme {Path.GetFileName(path)} is locked, keeping last known good" $"Custom theme {Path.GetFileName(path)} is locked, keeping last known good"
); );
if (cached.Theme is not null) if (cached.Theme is not null)
+179 -27
View File
@@ -277,7 +277,7 @@ public sealed class ChatLogWindow : Window
|| !GameFunctions.Chat.IsChannelOrExistingLinkshell(targetChannel.Value) || !GameFunctions.Chat.IsChannelOrExistingLinkshell(targetChannel.Value)
) )
{ {
Plugin.Log.Warning( Plugin.LogProxy.Warning(
$"Channel was set to an invalid value '{targetChannel}', ignoring" $"Channel was set to an invalid value '{targetChannel}', ignoring"
); );
return; return;
@@ -331,11 +331,11 @@ public sealed class ChatLogWindow : Window
{ {
case "hide": case "hide":
CurrentHideState = HideState.User; CurrentHideState = HideState.User;
Plugin.Log.Verbose("HideState: → User (chat hide command)"); Plugin.LogProxy.Verbose("HideState: → User (chat hide command)");
break; break;
case "show": case "show":
CurrentHideState = HideState.None; CurrentHideState = HideState.None;
Plugin.Log.Verbose("HideState: → None (chat show command)"); Plugin.LogProxy.Verbose("HideState: → None (chat show command)");
break; break;
case "toggle": case "toggle":
CurrentHideState = CurrentHideState switch CurrentHideState = CurrentHideState switch
@@ -345,7 +345,7 @@ public sealed class ChatLogWindow : Window
HideState.None => HideState.User, HideState.None => HideState.User,
_ => CurrentHideState, _ => CurrentHideState,
}; };
Plugin.Log.Verbose($"HideState: → {CurrentHideState} (chat toggle command)"); Plugin.LogProxy.Verbose($"HideState: → {CurrentHideState} (chat toggle command)");
break; break;
} }
} }
@@ -441,11 +441,24 @@ public sealed class ChatLogWindow : Window
private void TabSwitched(Tab newTab, Tab previousTab) private void TabSwitched(Tab newTab, Tab previousTab)
{ {
// Use the fixed channel if set by the user, or set it to the current tabs channel if this tab wasn't accessed before // Use the fixed channel if set by the user. Otherwise, if the new tab
// has no channel state yet (fresh from JSON, never selected this
// session), seed from the previous tab — but deep-clone so we don't
// share TellTarget with the previous tab. Without the clone, a later
// /tell on the new tab would mutate the pinned tab's TellTarget and
// the Party/Linkshell channel would pop back to the pinned tell-mark.
if (newTab.Channel is not null) if (newTab.Channel is not null)
{
newTab.CurrentChannel.Channel = newTab.Channel.Value; newTab.CurrentChannel.Channel = newTab.Channel.Value;
}
else if (newTab.CurrentChannel.Channel is InputChannel.Invalid) else if (newTab.CurrentChannel.Channel is InputChannel.Invalid)
newTab.CurrentChannel = previousTab.CurrentChannel; {
newTab.CurrentChannel = previousTab.CurrentChannel.Clone();
Plugin.LogProxy.Debug(
$"[Tab] '{newTab.Name}' seeded channel from '{previousTab.Name}' "
+ $"(Channel={newTab.CurrentChannel.Channel}, TellTarget={newTab.CurrentChannel.TellTarget?.ToTargetString() ?? "null"})"
);
}
SetChannel(newTab.CurrentChannel.Channel); SetChannel(newTab.CurrentChannel.Channel);
} }
@@ -469,14 +482,14 @@ public sealed class ChatLogWindow : Window
if (Plugin.Config.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle) if (Plugin.Config.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle)
{ {
CurrentHideState = HideState.Battle; CurrentHideState = HideState.Battle;
Plugin.Log.Verbose("HideState: None → Battle"); Plugin.LogProxy.Verbose("HideState: None → Battle");
} }
// If the chat is hidden because of battle, we reset it here // If the chat is hidden because of battle, we reset it here
if (CurrentHideState is HideState.Battle && !Plugin.InBattle) if (CurrentHideState is HideState.Battle && !Plugin.InBattle)
{ {
CurrentHideState = HideState.None; CurrentHideState = HideState.None;
Plugin.Log.Verbose("HideState: Battle → None"); Plugin.LogProxy.Verbose("HideState: Battle → None");
} }
// if the chat has no hide state and in a cutscene, set the hide state to cutscene // if the chat has no hide state and in a cutscene, set the hide state to cutscene
@@ -489,7 +502,7 @@ public sealed class ChatLogWindow : Window
if (Plugin.Functions.Chat.CheckHideFlags()) if (Plugin.Functions.Chat.CheckHideFlags())
{ {
CurrentHideState = HideState.Cutscene; CurrentHideState = HideState.Cutscene;
Plugin.Log.Verbose("HideState: None → Cutscene"); Plugin.LogProxy.Verbose("HideState: None → Cutscene");
} }
} }
@@ -500,7 +513,7 @@ public sealed class ChatLogWindow : Window
&& !Plugin.GposeActive && !Plugin.GposeActive
) )
{ {
Plugin.Log.Verbose($"HideState: {CurrentHideState} → None (cutscene/gpose ended)"); Plugin.LogProxy.Verbose($"HideState: {CurrentHideState} → None (cutscene/gpose ended)");
CurrentHideState = HideState.None; CurrentHideState = HideState.None;
} }
@@ -508,14 +521,14 @@ public sealed class ChatLogWindow : Window
if (CurrentHideState == HideState.Cutscene && Activate) if (CurrentHideState == HideState.Cutscene && Activate)
{ {
CurrentHideState = HideState.CutsceneOverride; CurrentHideState = HideState.CutsceneOverride;
Plugin.Log.Verbose("HideState: Cutscene → CutsceneOverride (user activate)"); Plugin.LogProxy.Verbose("HideState: Cutscene → CutsceneOverride (user activate)");
} }
// if the user hid the chat and is now activating chat, reset the hide state // if the user hid the chat and is now activating chat, reset the hide state
if (CurrentHideState == HideState.User && Activate) if (CurrentHideState == HideState.User && Activate)
{ {
CurrentHideState = HideState.None; CurrentHideState = HideState.None;
Plugin.Log.Verbose("HideState: User → None (activate)"); Plugin.LogProxy.Verbose("HideState: User → None (activate)");
} }
if ( if (
@@ -633,7 +646,7 @@ public sealed class ChatLogWindow : Window
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, "Error drawing Chat Log window"); Plugin.LogProxy.Error(ex, "Error drawing Chat Log window");
if (!NotifiedDrawFailure) if (!NotifiedDrawFailure)
{ {
Plugin.Notification.AddNotification( Plugin.Notification.AddNotification(
@@ -1608,7 +1621,7 @@ public sealed class ChatLogWindow : Window
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Warning(ex, "Error drawing chat log"); Plugin.LogProxy.Warning(ex, "Error drawing chat log");
} }
} }
@@ -1673,6 +1686,30 @@ public sealed class ChatLogWindow : Window
Plugin.WantedTab = null; Plugin.WantedTab = null;
} }
// Sidebar render order: persistent tabs in their original Plugin.Config.Tabs
// position, then pinned TempTabs, then unpinned TempTabs. Returns indices
// into Plugin.Config.Tabs so tabI in the loop body still mirrors the real
// list position (LastTab / WantedTab stay consistent).
private static List<int> BuildSidebarRenderOrder()
{
var tabs = Plugin.Config.Tabs;
var persistent = new List<int>(tabs.Count);
var pinned = new List<int>();
var unpinned = new List<int>();
for (var i = 0; i < tabs.Count; i++)
{
if (TabLifecycleHelpers.IsInPinnedPool(tabs[i]))
pinned.Add(i);
else if (TabLifecycleHelpers.IsInUnpinnedPool(tabs[i]))
unpinned.Add(i);
else
persistent.Add(i);
}
persistent.AddRange(pinned);
persistent.AddRange(unpinned);
return persistent;
}
private void DrawTabSidebar() private void DrawTabSidebar()
{ {
var currentTab = -1; var currentTab = -1;
@@ -1685,7 +1722,8 @@ public sealed class ChatLogWindow : Window
if (!tabTable.Success) if (!tabTable.Success)
return; return;
ImGui.TableSetupColumn("tabs", ImGuiTableColumnFlags.WidthFixed, 44f); var sidebarWidth = Math.Clamp(Plugin.Config.SidebarWidth, 44, 160);
ImGui.TableSetupColumn("tabs", ImGuiTableColumnFlags.WidthFixed, sidebarWidth);
ImGui.TableSetupColumn("chat", ImGuiTableColumnFlags.WidthStretch, 1); ImGui.TableSetupColumn("chat", ImGuiTableColumnFlags.WidthStretch, 1);
ImGui.TableNextColumn(); ImGui.TableNextColumn();
@@ -1704,23 +1742,42 @@ public sealed class ChatLogWindow : Window
ImGui.Dummy(new Vector2(0, ImGui.GetFrameHeightWithSpacing())); ImGui.Dummy(new Vector2(0, ImGui.GetFrameHeightWithSpacing()));
var previousTab = Plugin.CurrentTab; var previousTab = Plugin.CurrentTab;
// Divider rendered once before the first temp tab with a live unit counter. // Render order: persistent → pinned TempTabs → unpinned TempTabs.
// Underlying Plugin.Config.Tabs order is untouched (tabI mirrors
// the real list index), only the display sequence groups by
// section so each section can carry its own divider header.
var renderOrder = BuildSidebarRenderOrder();
var pinnedHeaderRendered = false;
var tempTabHeaderRendered = false; var tempTabHeaderRendered = false;
var tempTabCount = Plugin.Config.Tabs.Count(t => t.IsTempTab); var pinnedCount = Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool);
var unpinnedTempCount = Plugin.Config.Tabs.Count(
TabLifecycleHelpers.IsInUnpinnedPool
);
for (var tabI = 0; tabI < Plugin.Config.Tabs.Count; tabI++) foreach (var tabI in renderOrder)
{ {
var tab = Plugin.Config.Tabs[tabI]; var tab = Plugin.Config.Tabs[tabI];
if (tab.PopOut) if (tab.PopOut)
continue; continue;
if (tab.IsTempTab && !tempTabHeaderRendered) if (TabLifecycleHelpers.IsInPinnedPool(tab) && !pinnedHeaderRendered)
{ {
ImGui.Separator(); ImGui.Separator();
if (!Plugin.Config.AutoTellTabsCompactDisplay) if (!Plugin.Config.AutoTellTabsCompactDisplay)
{ {
ImGui.TextDisabled( ImGui.TextDisabled(
$"{HellionStrings.AutoTellTabs_SectionHeader} ({tempTabCount})" $"{HellionStrings.PinTab_SectionHeader} ({pinnedCount})"
);
}
pinnedHeaderRendered = true;
}
else if (TabLifecycleHelpers.IsInUnpinnedPool(tab) && !tempTabHeaderRendered)
{
ImGui.Separator();
if (!Plugin.Config.AutoTellTabsCompactDisplay)
{
ImGui.TextDisabled(
$"{HellionStrings.AutoTellTabs_SectionHeader} ({unpinnedTempCount})"
); );
} }
tempTabHeaderRendered = true; tempTabHeaderRendered = true;
@@ -1809,9 +1866,12 @@ public sealed class ChatLogWindow : Window
using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(iconColor))) using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(iconColor)))
using (Plugin.FontManager.FontAwesome.Push()) using (Plugin.FontManager.FontAwesome.Push())
{ {
// Button stretches with the configured sidebar width so a
// user-widened sidebar feels intentional, not a 36px icon
// floating in empty space.
clicked = ImGui.Button( clicked = ImGui.Button(
$"{icon.ToIconString()}##sidebar-tab-{tabI}", $"{icon.ToIconString()}##sidebar-tab-{tabI}",
new Vector2(36f, ImGui.GetFrameHeight()) new Vector2(sidebarWidth - 8f, ImGui.GetFrameHeight())
); );
} }
@@ -1871,11 +1931,35 @@ public sealed class ChatLogWindow : Window
); );
} }
// Pin indicator: subtle thumbtack glyph top-left of the icon.
// Muted colour because the "Pinned" section header already
// groups these tabs visually — this is just a per-tab
// confirmation glyph, not the primary discoverability cue.
if (tab.IsPinned)
{
var min = ImGui.GetItemRectMin();
const float pinPadding = 1f;
var pinPos = new Vector2(min.X + pinPadding, min.Y + pinPadding);
var pinColor = theme.Colors.TextMuted;
// Dim further so the glyph reads as a hint, not a badge.
var pinAbgr = ColourUtil.RgbaToAbgr(pinColor) & 0x77FFFFFFu;
using (Plugin.FontManager.FontAwesome.Push())
{
ImGui
.GetWindowDrawList()
.AddText(pinPos, pinAbgr, FontAwesomeIcon.Thumbtack.ToIconString());
}
}
// Tooltip mit Tab-Name + Unread-Counter beim Hover. // Tooltip mit Tab-Name + Unread-Counter beim Hover.
if (ImGui.IsItemHovered()) if (ImGui.IsItemHovered())
{ {
using var tt = ImRaii.Tooltip(); using var tt = ImRaii.Tooltip();
ImGui.TextUnformatted($"{tab.Name}{unread}"); ImGui.TextUnformatted($"{tab.Name}{unread}");
if (tab.IsPinned)
{
ImGui.TextUnformatted(HellionStrings.PinTab_PinnedTooltip);
}
} }
DrawTabContextMenu(tab, tabI); DrawTabContextMenu(tab, tabI);
@@ -1999,10 +2083,7 @@ public sealed class ChatLogWindow : Window
ImGui.TextUnformatted(FontAwesomeIcon.Crown.ToIconString()); ImGui.TextUnformatted(FontAwesomeIcon.Crown.ToIconString());
} }
ImGui.SameLine(0f, gapAfterCrown); ImGui.SameLine(0f, gapAfterCrown);
using (ImRaii.PushColor(ImGuiCol.Text, titleColor)) DrawHonorificTitleText(rendered, titleColor, title.Glow);
{
ImGui.TextUnformatted(rendered);
}
ImGui.EndGroup(); ImGui.EndGroup();
if (ImGui.IsItemHovered()) if (ImGui.IsItemHovered())
@@ -2013,6 +2094,35 @@ public sealed class ChatLogWindow : Window
ImGui.SameLine(); ImGui.SameLine();
} }
// Renders the title text, optionally with a glow outline pre-pass. Glow is
// drawn at 8 cardinal offsets (±1 px) in the glow colour at reduced alpha,
// then the primary text on top. The pre-pass uses the window draw list so
// it composites correctly with the regular ImGui text that follows.
private void DrawHonorificTitleText(string rendered, Vector4 titleColor, Vector3? glow)
{
if (Plugin.Config.ShowHonorificGlow && glow is { } g)
{
var pos = ImGui.GetCursorScreenPos();
var glowColor = new Vector4(g.X, g.Y, g.Z, 0.4f);
var glowAbgr = ImGui.ColorConvertFloat4ToU32(glowColor);
var drawList = ImGui.GetWindowDrawList();
for (var dy = -1; dy <= 1; dy++)
{
for (var dx = -1; dx <= 1; dx++)
{
if (dx == 0 && dy == 0)
continue;
drawList.AddText(new Vector2(pos.X + dx, pos.Y + dy), glowAbgr, rendered);
}
}
}
using (ImRaii.PushColor(ImGuiCol.Text, titleColor))
{
ImGui.TextUnformatted(rendered);
}
}
// One-time hint banner for the pop-out header button and right-click pathway. // One-time hint banner for the pop-out header button and right-click pathway.
private float DrawV061HintBannerIfNeeded() private float DrawV061HintBannerIfNeeded()
{ {
@@ -2059,7 +2169,7 @@ public sealed class ChatLogWindow : Window
{ {
Plugin.Config.SeenPopOutHeaderHint = true; Plugin.Config.SeenPopOutHeaderHint = true;
Plugin.SaveConfig(); Plugin.SaveConfig();
Plugin.Log.Debug("v0.6.1 pop-out header hint dismissed"); Plugin.LogProxy.Debug("v0.6.1 pop-out header hint dismissed");
if (openSettings) if (openSettings)
Plugin.SettingsWindow.Toggle(); Plugin.SettingsWindow.Toggle();
} }
@@ -2124,10 +2234,52 @@ public sealed class ChatLogWindow : Window
anyChanged = true; anyChanged = true;
} }
if (tab.IsTempTab)
{
ImGui.Separator();
DrawPinControls(tab);
}
if (anyChanged) if (anyChanged)
Plugin.SaveConfig(); 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 (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
{
ImGui.SetTooltip(
atCap
? string.Format(
HellionStrings.PinTab_LimitReached,
AutoTellTabsService.MaxPinnedTempTabs
)
: HellionStrings.PinTab_PinTooltip
);
}
}
}
internal readonly List<bool> PopOutDocked = []; internal readonly List<bool> PopOutDocked = [];
internal readonly HashSet<Guid> PopOutWindows = []; internal readonly HashSet<Guid> PopOutWindows = [];
@@ -2672,7 +2824,7 @@ public sealed class ChatLogWindow : Window
var viewport = ImGui.GetMainViewport(); var viewport = ImGui.GetMainViewport();
var safePos = viewport.WorkPos + SafeDefaultOffset; var safePos = viewport.WorkPos + SafeDefaultOffset;
Position = safePos; Position = safePos;
Plugin.Log.Info( Plugin.LogProxy.Info(
$"[Window-Recovery] {source}: snapping main window from {LastWindowPos} (size {LastWindowSize}) to {safePos}." $"[Window-Recovery] {source}: snapping main window from {LastWindowPos} (size {LastWindowSize}) to {safePos}."
); );
+2 -2
View File
@@ -307,7 +307,7 @@ public class DbViewer : Window
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, "Failed reading messages from database"); Plugin.LogProxy.Error(ex, "Failed reading messages from database");
} }
finally finally
{ {
@@ -570,7 +570,7 @@ public class DbViewer : Window
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, "Failed creating txt backup"); Plugin.LogProxy.Error(ex, "Failed creating txt backup");
Notification.Content = "Error ..."; Notification.Content = "Error ...";
Notification.Type = NotificationType.Error; Notification.Type = NotificationType.Error;
+7 -7
View File
@@ -175,7 +175,7 @@ internal class Popout : Window
{ {
Plugin.Config.SeenPopOutInputHint = true; Plugin.Config.SeenPopOutInputHint = true;
ChatLogWindow.Plugin.SaveConfig(); ChatLogWindow.Plugin.SaveConfig();
Plugin.Log.Debug("Pop-Out input hint dismissed"); Plugin.LogProxy.Debug("Pop-Out input hint dismissed");
if (openSettings) if (openSettings)
ChatLogWindow.Plugin.SettingsWindow.Toggle(); ChatLogWindow.Plugin.SettingsWindow.Toggle();
} }
@@ -214,13 +214,13 @@ internal class Popout : Window
if (Tab.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle) if (Tab.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle)
{ {
CurrentHideState = HideState.Battle; CurrentHideState = HideState.Battle;
Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: None -> Battle"); Plugin.LogProxy.Verbose($"Popout HideState [{Tab.Name}]: None -> Battle");
} }
if (CurrentHideState is HideState.Battle && !Plugin.InBattle) if (CurrentHideState is HideState.Battle && !Plugin.InBattle)
{ {
CurrentHideState = HideState.None; CurrentHideState = HideState.None;
Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: Battle -> None"); Plugin.LogProxy.Verbose($"Popout HideState [{Tab.Name}]: Battle -> None");
} }
if ( if (
@@ -232,7 +232,7 @@ internal class Popout : Window
if (ChatLogWindow.Plugin.Functions.Chat.CheckHideFlags()) if (ChatLogWindow.Plugin.Functions.Chat.CheckHideFlags())
{ {
CurrentHideState = HideState.Cutscene; CurrentHideState = HideState.Cutscene;
Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: None -> Cutscene"); Plugin.LogProxy.Verbose($"Popout HideState [{Tab.Name}]: None -> Cutscene");
} }
} }
@@ -242,7 +242,7 @@ internal class Popout : Window
&& !Plugin.GposeActive && !Plugin.GposeActive
) )
{ {
Plugin.Log.Verbose( Plugin.LogProxy.Verbose(
$"Popout HideState [{Tab.Name}]: {CurrentHideState} -> None (cutscene/gpose ended)" $"Popout HideState [{Tab.Name}]: {CurrentHideState} -> None (cutscene/gpose ended)"
); );
CurrentHideState = HideState.None; CurrentHideState = HideState.None;
@@ -251,7 +251,7 @@ internal class Popout : Window
if (CurrentHideState == HideState.Cutscene && ChatLogWindow.Activate) if (CurrentHideState == HideState.Cutscene && ChatLogWindow.Activate)
{ {
CurrentHideState = HideState.CutsceneOverride; CurrentHideState = HideState.CutsceneOverride;
Plugin.Log.Verbose( Plugin.LogProxy.Verbose(
$"Popout HideState [{Tab.Name}]: Cutscene -> CutsceneOverride (user activate)" $"Popout HideState [{Tab.Name}]: Cutscene -> CutsceneOverride (user activate)"
); );
} }
@@ -259,7 +259,7 @@ internal class Popout : Window
if (CurrentHideState == HideState.User && ChatLogWindow.Activate) if (CurrentHideState == HideState.User && ChatLogWindow.Activate)
{ {
CurrentHideState = HideState.None; CurrentHideState = HideState.None;
Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: User -> None (activate)"); Plugin.LogProxy.Verbose($"Popout HideState [{Tab.Name}]: User -> None (activate)");
} }
return CurrentHideState is HideState.Cutscene or HideState.User or HideState.Battle return CurrentHideState is HideState.Cutscene or HideState.User or HideState.Battle
+19 -15
View File
@@ -229,7 +229,7 @@ internal sealed class DataManagement : ISettingsTab
} }
catch (Exception e) catch (Exception e)
{ {
Plugin.Log.Error(e, "Unable to delete old database"); Plugin.LogProxy.Error(e, "Unable to delete old database");
WrapperUtil.AddNotification( WrapperUtil.AddNotification(
Language.Options_Database_Old_Delete_Error, Language.Options_Database_Old_Delete_Error,
NotificationType.Error NotificationType.Error
@@ -391,7 +391,9 @@ internal sealed class DataManagement : ISettingsTab
Plugin.Config.RetentionLastRunAt = DateTimeOffset.UtcNow; Plugin.Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
Plugin.SaveConfig(); Plugin.SaveConfig();
Plugin.Log.Information($"Manual retention run deleted {deleted} expired messages."); Plugin.LogProxy.Information(
$"Manual retention run deleted {deleted} expired messages."
);
if (deleted > 0) if (deleted > 0)
{ {
@@ -405,7 +407,7 @@ internal sealed class DataManagement : ISettingsTab
.Wait(TimeSpan.FromSeconds(5)) .Wait(TimeSpan.FromSeconds(5))
) )
{ {
Plugin.Log.Warning( Plugin.LogProxy.Warning(
"Retention sweep: framework refresh timed out after 5s." "Retention sweep: framework refresh timed out after 5s."
); );
} }
@@ -418,7 +420,7 @@ internal sealed class DataManagement : ISettingsTab
} }
catch (Exception e) catch (Exception e)
{ {
Plugin.Log.Error(e, "Manual retention run failed"); Plugin.LogProxy.Error(e, "Manual retention run failed");
WrapperUtil.AddNotification(HellionStrings.Retention_Error, NotificationType.Error); WrapperUtil.AddNotification(HellionStrings.Retention_Error, NotificationType.Error);
} }
finally finally
@@ -566,7 +568,7 @@ internal sealed class DataManagement : ISettingsTab
} }
catch (Exception e) catch (Exception e)
{ {
Plugin.Log.Error(e, "Failed to compute cleanup preview"); Plugin.LogProxy.Error(e, "Failed to compute cleanup preview");
WrapperUtil.AddNotification( WrapperUtil.AddNotification(
HellionStrings.Cleanup_PreviewError, HellionStrings.Cleanup_PreviewError,
NotificationType.Error NotificationType.Error
@@ -587,7 +589,7 @@ internal sealed class DataManagement : ISettingsTab
try try
{ {
var deleted = Plugin.MessageManager.Store.CleanupRetainOnly(allowed); var deleted = Plugin.MessageManager.Store.CleanupRetainOnly(allowed);
Plugin.Log.Information($"Privacy cleanup: deleted {deleted} messages"); Plugin.LogProxy.Information($"Privacy cleanup: deleted {deleted} messages");
if ( if (
!Plugin !Plugin
@@ -599,7 +601,9 @@ internal sealed class DataManagement : ISettingsTab
.Wait(TimeSpan.FromSeconds(5)) .Wait(TimeSpan.FromSeconds(5))
) )
{ {
Plugin.Log.Warning("Privacy cleanup: framework refresh timed out after 5s."); Plugin.LogProxy.Warning(
"Privacy cleanup: framework refresh timed out after 5s."
);
} }
WrapperUtil.AddNotification( WrapperUtil.AddNotification(
@@ -609,7 +613,7 @@ internal sealed class DataManagement : ISettingsTab
} }
catch (Exception e) catch (Exception e)
{ {
Plugin.Log.Error(e, "Privacy cleanup failed"); Plugin.LogProxy.Error(e, "Privacy cleanup failed");
WrapperUtil.AddNotification(HellionStrings.Cleanup_Error, NotificationType.Error); WrapperUtil.AddNotification(HellionStrings.Cleanup_Error, NotificationType.Error);
} }
finally finally
@@ -769,7 +773,7 @@ internal sealed class DataManagement : ISettingsTab
} }
catch (Exception e) catch (Exception e)
{ {
Plugin.Log.Error(e, "Export failed"); Plugin.LogProxy.Error(e, "Export failed");
WrapperUtil.AddNotification(HellionStrings.Export_Error, NotificationType.Error); WrapperUtil.AddNotification(HellionStrings.Export_Error, NotificationType.Error);
} }
finally finally
@@ -849,7 +853,7 @@ internal sealed class DataManagement : ISettingsTab
) )
) )
{ {
Plugin.Log.Warning("Clearing messages from database"); Plugin.LogProxy.Warning("Clearing messages from database");
Plugin.MessageManager.Store.ClearMessages(); Plugin.MessageManager.Store.ClearMessages();
Plugin.MessageManager.ClearAllTabs(); Plugin.MessageManager.ClearAllTabs();
@@ -907,7 +911,7 @@ internal sealed class DataManagement : ISettingsTab
private void InsertMessages(int count) private void InsertMessages(int count)
{ {
Plugin.Log.Info($"Inserting {count} messages due to user request"); Plugin.LogProxy.Info($"Inserting {count} messages due to user request");
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
var playerName = Plugin.PlayerState.CharacterName; var playerName = Plugin.PlayerState.CharacterName;
@@ -952,7 +956,7 @@ internal sealed class DataManagement : ISettingsTab
var elapsedTicks = stopwatch.ElapsedTicks; var elapsedTicks = stopwatch.ElapsedTicks;
stopwatch.Stop(); stopwatch.Stop();
Plugin.Log.Info( Plugin.LogProxy.Info(
$"Crafted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)" $"Crafted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)"
); );
@@ -962,7 +966,7 @@ internal sealed class DataManagement : ISettingsTab
elapsedTicks = stopwatch.ElapsedTicks; elapsedTicks = stopwatch.ElapsedTicks;
stopwatch.Stop(); stopwatch.Stop();
Plugin.Log.Info( Plugin.LogProxy.Info(
$"Upserted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)" $"Upserted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)"
); );
@@ -973,7 +977,7 @@ internal sealed class DataManagement : ISettingsTab
Plugin.MessageManager.ClearAllTabs(); Plugin.MessageManager.ClearAllTabs();
elapsedTicks = stopwatch.ElapsedTicks; elapsedTicks = stopwatch.ElapsedTicks;
stopwatch.Stop(); stopwatch.Stop();
Plugin.Log.Info( Plugin.LogProxy.Info(
$"Cleared {Plugin.Config.Tabs.Count} tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)" $"Cleared {Plugin.Config.Tabs.Count} tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)"
); );
}) })
@@ -986,7 +990,7 @@ internal sealed class DataManagement : ISettingsTab
Plugin.MessageManager.FilterAllTabs(); Plugin.MessageManager.FilterAllTabs();
elapsedTicks = stopwatch.ElapsedTicks; elapsedTicks = stopwatch.ElapsedTicks;
stopwatch.Stop(); stopwatch.Stop();
Plugin.Log.Info( Plugin.LogProxy.Info(
$"Fetched and filtered all tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)" $"Fetched and filtered all tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)"
); );
}) })
@@ -312,6 +312,6 @@ internal sealed class FontsAndColours : ISettingsTab
} }
Plugin.SaveConfig(); Plugin.SaveConfig();
GlobalParametersCache.Refresh(); GlobalParametersCache.Refresh();
Plugin.Log.Debug($"Applied chat colour preset: {preset.DisplayName}"); Plugin.LogProxy.Debug($"Applied chat colour preset: {preset.DisplayName}");
} }
} }
@@ -71,6 +71,17 @@ internal sealed class Integrations : ISettingsTab
{ {
ImGui.TextWrapped(HellionStrings.Settings_Integrations_Honorific_ToggleHint); ImGui.TextWrapped(HellionStrings.Settings_Integrations_Honorific_ToggleHint);
} }
if (
ImGui.Checkbox(
HellionStrings.Settings_Integrations_Honorific_Glow_Toggle,
ref Mutable.ShowHonorificGlow
)
)
{
Plugin.SaveConfig();
}
ImGuiUtil.HelpMarker(HellionStrings.Settings_Integrations_Honorific_Glow_Hint);
} }
// Honorific has no LICENSE in its repo so we link upstream and author // Honorific has no LICENSE in its repo so we link upstream and author
+21 -1
View File
@@ -90,7 +90,7 @@ internal sealed class ThemeAndLayout : ISettingsTab
var path = Path.Combine(dir, fileName); var path = Path.Combine(dir, fileName);
var json = ThemeJsonWriter.Serialize(active); var json = ThemeJsonWriter.Serialize(active);
File.WriteAllText(path, json); File.WriteAllText(path, json);
Plugin.Log.Information($"Exported active theme '{active.Slug}' to {path}"); Plugin.LogProxy.Information($"Exported active theme '{active.Slug}' to {path}");
} }
} }
} }
@@ -250,6 +250,26 @@ internal sealed class ThemeAndLayout : ISettingsTab
string.Format(Language.Options_SidebarTabView_Description, Plugin.PluginName) string.Format(Language.Options_SidebarTabView_Description, Plugin.PluginName)
); );
if (Mutable.SidebarTabView)
{
var sidebarWidth = Mutable.SidebarWidth;
if (
ImGui.SliderInt(
HellionStrings.Settings_ThemeAndLayout_SidebarWidth_Name,
ref sidebarWidth,
44,
160,
$"{sidebarWidth} px"
)
)
{
Mutable.SidebarWidth = sidebarWidth;
}
ImGuiUtil.HelpMarker(
HellionStrings.Settings_ThemeAndLayout_SidebarWidth_Description
);
}
ImGui.Spacing(); ImGui.Spacing();
ImGui.Separator(); ImGui.Separator();
ImGui.Spacing(); ImGui.Spacing();
+2 -2
View File
@@ -62,7 +62,7 @@ internal static class AutoTranslate
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
AllEntries(); AllEntries();
Plugin.Log.Debug($"Warming up auto-translate took {sw.ElapsedMilliseconds}ms"); Plugin.LogProxy.Debug($"Warming up auto-translate took {sw.ElapsedMilliseconds}ms");
}) })
{ {
IsBackground = true, IsBackground = true,
@@ -197,7 +197,7 @@ internal static class AutoTranslate
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error(ex, $"failed to translate: {lookup}"); Plugin.LogProxy.Error(ex, $"failed to translate: {lookup}");
} }
} }
+61
View File
@@ -0,0 +1,61 @@
using System;
using Dalamud.Plugin.Services;
namespace HellionChat.Util;
internal sealed class DalamudPluginLogProxy : IPluginLogProxy
{
private readonly IPluginLog _log;
public DalamudPluginLogProxy(IPluginLog log) => _log = log;
public void Verbose(string message) => _log.Verbose(message);
public void Verbose(Exception exception, string message) => _log.Verbose(exception, message);
public void Verbose(string messageTemplate, params object[] values) =>
_log.Verbose(messageTemplate, values);
public void Debug(string message) => _log.Debug(message);
public void Debug(Exception exception, string message) => _log.Debug(exception, message);
public void Debug(string messageTemplate, params object[] values) =>
_log.Debug(messageTemplate, values);
public void Information(string message) => _log.Information(message);
public void Information(Exception exception, string message) =>
_log.Information(exception, message);
public void Information(string messageTemplate, params object[] values) =>
_log.Information(messageTemplate, values);
public void Info(string message) => _log.Info(message);
public void Info(Exception exception, string message) => _log.Info(exception, message);
public void Info(string messageTemplate, params object[] values) =>
_log.Info(messageTemplate, values);
public void Warning(string message) => _log.Warning(message);
public void Warning(Exception exception, string message) => _log.Warning(exception, message);
public void Warning(string messageTemplate, params object[] values) =>
_log.Warning(messageTemplate, values);
public void Error(string message) => _log.Error(message);
public void Error(Exception exception, string message) => _log.Error(exception, message);
public void Error(string messageTemplate, params object[] values) =>
_log.Error(messageTemplate, values);
public void Fatal(string message) => _log.Fatal(message);
public void Fatal(Exception exception, string message) => _log.Fatal(exception, message);
public void Fatal(string messageTemplate, params object[] values) =>
_log.Fatal(messageTemplate, values);
}
+40
View File
@@ -0,0 +1,40 @@
using System;
namespace HellionChat.Util;
// Indirection over Dalamud's IPluginLog so MessageStore can be constructed
// in an isolated xUnit AppDomain without loading Dalamud.dll — same pattern
// as IPlatformUtil from F12.1. A later DI-container cycle (v1.5.x) may
// replace this with Microsoft.Extensions.Logging's ILogger<T>.
internal interface IPluginLogProxy
{
void Verbose(string message);
void Verbose(Exception exception, string message);
void Verbose(string messageTemplate, params object[] values);
void Debug(string message);
void Debug(Exception exception, string message);
void Debug(string messageTemplate, params object[] values);
void Information(string message);
void Information(Exception exception, string message);
void Information(string messageTemplate, params object[] values);
// IPluginLog exposes Info as a distinct method (short alias of
// Information) — both are present so call-sites stay drop-in.
void Info(string message);
void Info(Exception exception, string message);
void Info(string messageTemplate, params object[] values);
void Warning(string message);
void Warning(Exception exception, string message);
void Warning(string messageTemplate, params object[] values);
void Error(string message);
void Error(Exception exception, string message);
void Error(string messageTemplate, params object[] values);
void Fatal(string message);
void Fatal(Exception exception, string message);
void Fatal(string messageTemplate, params object[] values);
}
+3 -1
View File
@@ -583,7 +583,9 @@ internal static class ImGuiUtil
using (ImRaii.Disabled(isMax)) using (ImRaii.Disabled(isMax))
{ {
if (IconButton(FontAwesomeIcon.ArrowRight, id + 1.ToString())) // Parentheses pin the operator precedence: without them this resolves as
// id.ToString() + "1" (e.g. "01" instead of "1").
if (IconButton(FontAwesomeIcon.ArrowRight, (id + 1).ToString()))
selected++; selected++;
} }
+1 -1
View File
@@ -42,6 +42,6 @@ public static class MemoryUtil
str.Append(' '); str.Append(' ');
} }
Plugin.Log.Information(str.ToString()); Plugin.LogProxy.Information(str.ToString());
} }
} }
+16
View File
@@ -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);
}
+2 -2
View File
@@ -21,12 +21,12 @@ public static class WrapperUtil
{ {
try try
{ {
Plugin.Log.Debug($"Opening URI {uri} in default browser"); Plugin.LogProxy.Debug($"Opening URI {uri} in default browser");
Plugin.PlatformUtil.OpenLink(uri.ToString()); Plugin.PlatformUtil.OpenLink(uri.ToString());
} }
catch (Exception ex) catch (Exception ex)
{ {
Plugin.Log.Error($"Error opening URI: {ex}"); Plugin.LogProxy.Error($"Error opening URI: {ex}");
AddNotification(Language.Context_OpenInBrowserError, NotificationType.Error); AddNotification(Language.Context_OpenInBrowserError, NotificationType.Error);
} }
} }
+19 -16
View File
@@ -2,7 +2,7 @@
[![Build](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/actions/workflows/build.yml/badge.svg?branch=main)](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/actions/workflows/build.yml) [![Build](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/actions/workflows/build.yml/badge.svg?branch=main)](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/actions/workflows/build.yml)
[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE) [![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE)
[![Latest release](https://img.shields.io/badge/release-v1.4.6-brightgreen)](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/latest) [![Latest release](https://img.shields.io/badge/release-v1.4.7-brightgreen)](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/latest)
[![Dalamud API](https://img.shields.io/badge/Dalamud-API_15-purple)](https://github.com/goatcorp/Dalamud) [![Dalamud API](https://img.shields.io/badge/Dalamud-API_15-purple)](https://github.com/goatcorp/Dalamud)
[![.NET](https://img.shields.io/badge/.NET-10.0-512BD4)](https://dotnet.microsoft.com/) [![.NET](https://img.shields.io/badge/.NET-10.0-512BD4)](https://dotnet.microsoft.com/)
[![FFXIV](https://img.shields.io/badge/FFXIV-Dawntrail-c3a37f)](https://www.finalfantasyxiv.com/) [![FFXIV](https://img.shields.io/badge/FFXIV-Dawntrail-c3a37f)](https://www.finalfantasyxiv.com/)
@@ -11,7 +11,7 @@
<img src="docs/images/hellion-forge.png" alt="Hellion Forge" width="180" /> <img src="docs/images/hellion-forge.png" alt="Hellion Forge" width="180" />
</p> </p>
**Version 1.4.6** — Privacy-first chat plugin for FINAL FANTASY XIV / Dalamud, built on **Version 1.4.7** — Privacy-first chat plugin for FINAL FANTASY XIV / Dalamud, built on
[Chat 2](https://github.com/Infiziert90/ChatTwo) (EUPL-1.2). [Chat 2](https://github.com/Infiziert90/ChatTwo) (EUPL-1.2).
Hellion Chat is a privacy-first plugin built on the Chat 2 foundation. The majority of the engine comes from Chat 2 Hellion Chat is a privacy-first plugin built on the Chat 2 foundation. The majority of the engine comes from Chat 2
@@ -286,20 +286,23 @@ An optional submission to the Dalamud main plugin repo (in addition to the custo
## Project Status ## Project Status
**Version 1.4.6**Maintenance patch. No user-visible behaviour changes; tightens the development feedback loop and **Version 1.4.7**Backlog cleanup and the first user-visible feature bundle since v1.4.5. TempTell tabs can now be
pulls in two ChatTwo upstream bugfixes. `scripts/preflight.sh` gains a csharpier reflow check and a markdownlint pass at pinned via right-click; pinned tabs survive relog, keep their conversation history (loaded on demand from the message
the pre-push gate. `FontManager`'s font-fallback catch-filter now covers `InvalidOperationException` and store), and stay bound to the same `/tell` partner. A hard cap of 5 pinned tabs lives in a pool separate from the 15-tab
`ArgumentException` on top of the IO triad, so a corrupted font config no longer takes down the atlas build. auto-tell pool, so the total ceiling is 20 tabs. The sidebar groups pinned tabs into their own section with its own
`BrandingLinks` and `IntegrationLinks` URLs validate themselves on plugin load — a typo in a future URL rotation throws divider header. Honorific glow outlines now render when the title carries a Glow colour — opt-in via **Settings →
at startup instead of failing silently when a user clicks the broken button. Cherry-picked from ChatTwo upstream Integrations → Render glow outlines (Honorific)**, default off, so v1.4.6 visuals stay untouched for users who don't
`f35b7d3`: `Chat.SetChannel` no longer leaks the native `Utf8String` when the linkshell check rejects the channel, and care and the per-frame DrawList overhead is skipped on low-end hardware. Honorific gradient (Color3 / GradientColourSet
`Tab.Clone` now deep-clones `UsedChannel` and `TellTarget` (the previous reference copy let PopOut and Temp tabs mutate / Wave / Pulse) is parsed and stashed for a later cycle, but currently renders as the primary colour. Sidebar width is
each other's channel state). The active-tab underline pill scales with DPI and rounds to physical pixels for crisp configurable in **Theme & Layout** between 44 and 160 px; default stays icon-only so existing users see no layout
rendering above 100 % DPI. Internal items: `HellionStyle` ChildBgAlpha extracted to a testable helper, change. `Configuration.UpdateFrom` now preserves the runtime `CurrentChannel` across the persistent-tab merge, and
`Plugin.SaveConfig` clones only the temp-tab subset, `SettingsOverview` caches the draw-list per frame, `TabSwitched` deep-clones the seeded channel — together they fix a Settings-Save regression where the chat input could
`Dalamud.Utility.Util` static surface routed through an `IPlatformUtil` indirection (`MessageStore`'s `IsWine` probe is pop back to `/tell <pinned-partner>` after touching settings while on a Party or Linkshell tab. Internal items:
now testable in isolation). No schema bump, no migration. Seventh sub-patch of the v1.4.x polish sweep series (as of `IPluginLogProxy` indirection over Dalamud's `IPluginLog` routes all ~91 `Plugin.Log` call sites through a testable
2026-05-12). proxy, closing the test-isolation gap F12.1 left in v1.4.6 (`MessageStore.Migrate0` now runs in xUnit without loading
`Dalamud.dll`). `Util/ImGuiUtil.cs`'s `DrawArrows` IconButton id gets explicit parentheses on the increment. Migration
v16 → v17 is additive (new `Tab.IsPinned` flag, default false). Eighth sub-patch of the v1.4.x polish sweep series (as
of 2026-05-13).
Hellion Chat is a standalone plugin, no longer a fork in the repository sense. Fully completed: Hellion Chat is a standalone plugin, no longer a fork in the repository sense. Fully completed:
+39
View File
@@ -10,6 +10,45 @@ to the release pages for details.
--- ---
## Hellion Chat 1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)
Eighth sub-patch of the v1.4.x polish-sweep series. First user-visible feature bundle since v1.4.5 — pinned tell tabs
that survive relog, opt-in Honorific glow rendering, a configurable sidebar, plus a Settings-Save channel-preservation
fix surfaced during smoke testing.
- TempTell Pin: right-click a TempTell tab in the sidebar and choose "Pin Tab" / "Tab anpinnen". Pinned tabs survive
plugin reload and character logout, keep their conversation history (loaded on demand from the message store on
rehydrate), and stay bound to the same `/tell` partner. Hard cap of 5 pinned tabs in a pool separate from the 15-tab
auto-tell pool — total ceiling is 20 tabs. The sidebar groups pinned tabs into their own section with a divider header
- Honorific glow outlines now render via an 8-direction DrawList pre-pass when the title carries a Glow colour. Opt-in
via **Settings → Integrations → Render glow outlines (Honorific)** (default off). Honorific's gradient surface
(`Color3`, `GradientColourSet`, `GradientAnimationStyle`) is parsed and stashed for a later cycle but renders as the
primary colour until then — the v1.4.7 DTO already mirrors all four extra fields so the JSON roundtrip doesn't
silent-drop them
- Sidebar width configurable in **Theme & Layout** (44160 px, default 44 stays icon-only). The icon button stretches
with the configured width so a widened sidebar looks intentional, not a 36 px icon floating in empty space
- `Configuration.UpdateFrom` now preserves the runtime `CurrentChannel` across the persistent-tab merge alongside
`Messages` and `LastSendUnread`. `TabSwitched` deep-clones the seeded channel from the previous tab instead of sharing
the same `UsedChannel` instance. Together these fix a regression where Settings-Save on a Party or Linkshell tab
popped the chat input back to `/tell <pinned-partner>` on the next interaction
- `Util/ImGuiUtil.cs` `DrawArrows` IconButton id uses `(id + 1).ToString()` with explicit parentheses instead of the
operator-precedence quirk `id + 1.ToString()` (which resolved to `id.ToString() + "1"`). Single live caller is
`Ui/DbViewer.cs:227` page-navigation
- Internal: `IPluginLogProxy` indirection over Dalamud's `IPluginLog` routes all ~91 `Plugin.Log` call sites through a
testable proxy. `MessageStore.Migrate0` can now run in xUnit without loading `Dalamud.dll`, closing the gap F12.1 left
in v1.4.6. Production wrapper `DalamudPluginLogProxy` and Build-Suite `FakePluginLogProxy` mirror the full
`IPluginLog` surface (`Verbose`/`Debug`/`Information`/`Info`/`Warning`/`Error`/`Fatal`) with single-string,
`Exception+string`, and `params object[]` overloads
- Internal: TempTab counter switched from an `Interlocked` cached field to a derived `Tabs.Count(predicate)`. Pin-state
transitions (TryPin / Unpin / Promote) are cold-path and don't need lock-free reads; counter mutation surface dropped
from 5 to 0 sites. Build-Suite floor 688 → 710 (+22)
- Schema bump v16 → v17 is additive: new `Tab.IsPinned` bool, default false. Existing v16 configs load cleanly and get
their `Version` stamp bumped after the gate check
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
---
## Hellion Chat 1.4.6 — Code Hygiene and Refactor (2026-05-12) ## Hellion Chat 1.4.6 — Code Hygiene and Refactor (2026-05-12)
Maintenance patch. No user-visible behaviour changes; tightens the development feedback loop, fixes two Maintenance patch. No user-visible behaviour changes; tightens the development feedback loop, fixes two
+24 -4
View File
@@ -10,14 +10,34 @@ the plugin's privacy-first scope during brainstorming.
--- ---
## Next Cycle (v1.4.7) ## Next Cycle (v1.4.8)
**Backlog Cleanup.** Roll up the remaining audit items deferred from v1.4.0v1.4.6 and the new entries surfaced during **Hook-Layer Cycle.** Receive-suppressed-tells toggle (cross-reference XIVIM #73 bubble-layer sub-task), Database Viewer
v1.4.6 (notably the `Plugin.Log` indirection that would unlock fully isolated `MessageStore` construction tests, plus full-text search via SQLite FTS5, plus preparation for the later Ad-Block cycle. Hook-layer investigation is shared
follow-up scope hinted at in the ChatTwo upstream f35b7d3 cherry-picks). Scope is consolidated during brainstorm. across these items so they cluster naturally in one sub-patch.
--- ---
## v1.4.7 — Backlog Cleanup and Mid-Features (released 2026-05-13)
Eighth sub-patch of the v1.4.x Polish Sweep series. First user-visible feature bundle since v1.4.5. TempTell tabs can
now be pinned via right-click; pinned tabs survive plugin reload and character logout, keep their conversation history
(loaded on demand from the message store on rehydrate), and stay bound to the same `/tell` partner. A hard cap of 5
pinned tabs lives in a pool separate from the 15-tab auto-tell pool, total ceiling 20. The sidebar groups pinned tabs
into their own section with a divider header, and the sidebar width itself is now configurable in **Theme & Layout**
between 44 and 160 px. Honorific glow outlines render when the title carries a Glow colour, opt-in via **Settings →
Integrations → Render glow outlines (Honorific)** (default off). Honorific's gradient (Color3 / GradientColourSet / Wave
/ Pulse) is parsed but rendered statically — a later cycle will port the full animation algorithm or land an upstream
IPC PR for the resolved frame colour. `Configuration.UpdateFrom` now preserves the runtime `CurrentChannel` across the
persistent-tab merge, and `TabSwitched` deep-clones the seeded channel instead of sharing the previous tab's
`UsedChannel` — together they fix a Settings-Save regression where the chat input could pop back to
`/tell <pinned-partner>` after touching settings on a Party or Linkshell tab. Internal items: `IPluginLogProxy`
indirection over Dalamud's `IPluginLog` routes all ~91 `Plugin.Log` call sites through a testable proxy, closing the
F12.1 test-isolation gap (`MessageStore.Migrate0` runs in xUnit now). TempTab counter switched from `Interlocked` cached
field to derived `Tabs.Count(predicate)`. Migration v16 → v17 is additive (new `Tab.IsPinned` flag). Build-Suite floor
688 → 710 (+22 tests across Pin-lifecycle predicates, pool limits, Tab.Clone roundtrip, MessageStore Migrate0
construction, and Honorific TitleData JSON roundtrip).
## v1.4.6 — Code Hygiene and Refactor (released 2026-05-12) ## v1.4.6 — Code Hygiene and Refactor (released 2026-05-12)
Seventh sub-patch of the v1.4.x Polish Sweep series. Maintenance patch — no user-visible behaviour changes; tightens the Seventh sub-patch of the v1.4.x Polish Sweep series. Maintenance patch — no user-visible behaviour changes; tightens the
+6 -6
View File
File diff suppressed because one or more lines are too long