feat(tabs): add IsPinned with separate pool and 5-tab cap

Tester-Request from Jin (2026-05-03): TempTabs should be pinnable so a
key conversation partner survives a relog. Right-click a TempTab and
choose Pin Tab / Unpin Tab / Promote to permanent.

Pool semantics:
- AutoTellTabsLimit (15) still gates the auto-managed unpinned pool.
- Pinned TempTabs live in their own pool, hard-capped at 5.
- The 6th pin attempt fails with a notification; users can unpin first
  or promote to permanent.
- Unpinning into a full unpinned pool drops the oldest unpinned (no
  user friction).

Mechanics:
- Tab.IsPinned (default false); Tab.Clone() carries it.
- Migration v16 -> v17 (additive; existing tabs default to unpinned).
- Three strip-sites synchronised through TabLifecycleHelpers:
  Plugin.cs load-time, Plugin.SaveConfig, Configuration.UpdateFrom.
- AutoTellTabsService:
  * MaxPinnedTempTabs constant.
  * F2.1 _activeTempTabCount counter retired — ActiveTempTabCount is
    now Tabs.Count(predicate). Pin/Unpin/Promote transitions are
    cold-path and don't need lock-free reads.
  * DropOldestTempTab filters on IsInUnpinnedPool so pinned tabs are
    never drop candidates.
  * OnLogout strips only the unpinned pool; pinned popouts and the
    active-tab switch behave correspondingly.
  * TryPin / Unpin / PromoteToPermanent service methods.
- ChatLogWindow tab context menu: Pin / Unpin / Promote with disabled-
  state at-cap tooltip + Promote tooltip explaining the channel-filter
  side effect.
- HellionStrings (EN+DE) for menu labels, tooltips, the limit warning.
- AutoTellTabsLimit slider description now flags the separate pinned
  pool so users aren't surprised by 18 tabs when the limit reads 15.
This commit is contained in:
2026-05-13 09:06:14 +02:00
parent fee2459e73
commit fd5f970a8b
8 changed files with 222 additions and 66 deletions
+18 -20
View File
@@ -169,20 +169,22 @@ public sealed class Plugin : IAsyncDalamudPlugin
PlatformUtil = new DalamudPlatformUtil();
LogProxy = new DalamudPluginLogProxy(Log);
// Schema gate: v1.4.x requires config v16. Users on older schemas
// must install v1.4.2 first to run the migration chain.
// Schema gate: v1.4.x requires config v16+. Users on older schemas
// must install v1.4.2 first to run the migration chain. v17 adds
// Tab.IsPinned (additive, no data migration needed) so v16 configs
// load cleanly and get their Version stamp bumped after the gate.
if (Config.Version < 16)
{
throw new InvalidOperationException(
$"HellionChat v1.4.6 requires config schema v16, got v{Config.Version}. "
+ "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.6."
$"HellionChat v1.4.7 requires config schema v16, got v{Config.Version}. "
+ "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.7."
);
}
Config.Version = 17;
// Session-only tabs are stripped on every load; AutoTellTabsService.Initialize
// then re-pegs TempTabCounter from the stripped list, not the pre-strip snapshot.
// TEST-MIRROR: ../../Hellion Build test/_Helpers/TempTabCounterTests.cs
Config.Tabs.RemoveAll(t => t.IsTempTab);
// Unpinned TempTabs are session-only and dropped on every load. Pinned
// TempTabs survive reload — Jin's tester feedback (v1.4.7).
Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnLoad);
LanguageChanged(Interface.UiLanguage);
ImGuiUtil.Initialize(this);
@@ -652,21 +654,17 @@ public sealed class Plugin : IAsyncDalamudPlugin
internal void SaveConfig()
{
// Session-only Auto-Tell-Tabs aren't persisted, so they move aside
// before serialization and re-attach after. Cloning only the temp
// subset keeps the allocation proportional to AutoTellTabsLimit
// (<=15) instead of the full tab list.
var tempTabs = Config.Tabs.Where(t => t.IsTempTab).ToList();
Config.Tabs.RemoveAll(t => t.IsTempTab);
// Only unpinned TempTabs are session-only — they move aside before
// serialization and re-attach after. Pinned TempTabs stay in
// Config.Tabs across the save so JSON includes them. Cloning only the
// unpinned subset keeps the allocation proportional to
// AutoTellTabsLimit (<=15) instead of the full tab list.
var unpinnedTempTabs = Config.Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnSave);
Interface.SavePluginConfig(Config);
Config.Tabs.AddRange(tempTabs);
// F2.1: the mid-step RemoveAll bypasses AutoTellTabsService, so
// re-peg the counter. Null-conditional because SaveConfig can fire
// before Phase-2 init.
AutoTellTabsService?.ResyncTempTabCounter();
Config.Tabs.AddRange(unpinnedTempTabs);
}
internal void LanguageChanged(string langCode)