Merge branch 'feature/v1.4.8'

This commit is contained in:
2026-05-14 12:12:06 +02:00
17 changed files with 1149 additions and 309 deletions
+21
View File
@@ -0,0 +1,21 @@
---
subtitle: Hook-Layer und Polish-Quick-Wins
versionsnatur: Polish-Patch
---
- DbViewer Volltext-Suche: optionaler FTS5-Index über die ganze Chat-Historie.
Wird beim ersten v1.4.8-Start asynchron im Hintergrund gebaut, Progress als
Toast. Lokale Page-Suche bleibt Default. Such-Eingaben werden als exakte
Wortfolge gematcht; mehrere Wörter werden nur gefunden, wenn sie zusammen
und in der Reihenfolge stehen. Wer rohe FTS5-MATCH-Syntax nutzen will, setzt
eigene Anführungszeichen um den Suchbegriff.
- Custom-Theme-Files laden sich beim Speichern automatisch neu, wenn das Theme
aktiv ist. Kein Picker-Klick mehr nötig.
- Retention-Sweep blockt nicht mehr den Framework-Thread. Der Mini-Hitch von
~194ms pro Sweep ist weg.
- Statusleiste rendert sauber bei Windows-Skalierung über 100%.
- Receive-Suppressed-Tells-Routing wurde in diesem Cycle untersucht und auf
v1.5.x verschoben: wenn andere Plugins Tells via CheckMessageHandled
unterdrücken, überspringt FFXIVs Chat-Pipeline den RaptureLogModule-Resolver
und HellionChats Tab-Routing verliert den Tell-Partner. Der Fix liegt
architektonisch neben dem geplanten Ad-Block-Hook-Layer und kommt dort mit.
+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.7</Version> <Version>1.4.8</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 -->
+34 -20
View File
@@ -35,6 +35,40 @@ tags:
- Replacement - Replacement
- Privacy - Privacy
changelog: |- changelog: |-
**v1.4.8 — Hook-Layer and Polish Quick-Wins (2026-05-14)**
Ninth sub-patch of the v1.4.x polish-sweep series. Hook-layer
cluster (DbViewer FTS5 full-text search, ad-block foundation
investigation) plus three polish quick-wins.
- DbViewer full-text search: optional FTS5 index across the full
chat history. Built asynchronously on first load after the
update with a progress toast. The local page-filter remains
available as the default mode. Queries match as exact phrases
-- multi-word terms must appear together in order; advanced
users can opt into raw FTS5 MATCH syntax by wrapping their own
double-quotes.
- Custom theme files now auto-reload when edited while the theme
is active -- no need to re-click the theme in the picker.
- Retention sweep no longer blocks the framework thread, removing
the ~194ms mini-hitch per sweep.
- Status bar renders correctly at Windows display scaling > 100%.
- Receive-suppressed-tells routing investigated this cycle and
postponed to v1.5.x: when other plugins suppress tells via
CheckMessageHandled, the FFXIV chat pipeline skips the
RaptureLogModule.AddMsgSourceEntry path so HellionChat's
ContentIdResolverHook does not fire and tell-partner
identification breaks. The fix belongs next to the planned
ad-block hook layer where the same patch surface comes up.
- Internal: messages.Id is declared BLOB but stored as TEXT
(Microsoft.Data.Sqlite Guid binding). FTS bulk insert and
LoadByGuids match the TEXT storage form on both sides.
Migration v17 stays (no schema bump).
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
---
**v1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)** **v1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)**
Eighth sub-patch of the v1.4.x polish-sweep series. First Eighth sub-patch of the v1.4.x polish-sweep series. First
@@ -140,24 +174,4 @@ changelog: |-
--- ---
**v1.4.4 — Threading and IPC safety polish (2026-05-12)**
Fifth sub-patch of the v1.4.x polish-sweep series. Threading
assumptions are documented per-method, a hot-path lock falls
away, and the privacy filter speaks up when an unknown ChatType
shows up.
- AutoTellTabs hot-path getter uses an Interlocked counter
instead of taking the lock on every read
- Honorific integration: per-method threading banners, plus
Warning-level log on unsubscribe failure
- AutoTranslate warmup thread marked IsBackground so plugin
unload doesn't wait for it
- PrivacyFilter logs once per unknown ChatType so a future
patch's added channel doesn't drop off the radar
- New installs persist unknown channels by default; existing
configs keep their explicit choice
---
Full history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases Full history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases
File diff suppressed because it is too large Load Diff
+175 -8
View File
@@ -14,6 +14,7 @@ using HellionChat.Ipc;
using HellionChat.Resources; using HellionChat.Resources;
using HellionChat.Ui; using HellionChat.Ui;
using HellionChat.Util; using HellionChat.Util;
using Microsoft.Data.Sqlite;
namespace HellionChat; namespace HellionChat;
@@ -125,8 +126,20 @@ public sealed class Plugin : IAsyncDalamudPlugin
// 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;
// Set in the first DisposeAsync statement so async callbacks scheduled
// via Framework.RunOnTick (v1.4.8 B3 retention sweep) can early-bail
// before they touch state that has already been torn down. Volatile
// because the tick reads it from a different thread than the writer.
private volatile bool _isDisposing;
internal int DeferredSaveFrames = -1; internal int DeferredSaveFrames = -1;
// Cancels the v1.4.8 FTS5 bulk-insert worker on plugin teardown. The
// worker runs off the framework thread on its own SqliteConnection, so a
// Dispose mid-rebuild must signal cancellation before MessageManager
// tears down (the worker logs "rebuild failed" via Log on error paths).
private CancellationTokenSource? _ftsRebuildCts;
// Serialises retention sweeps so a manual trigger and the 24h auto-sweep // Serialises retention sweeps so a manual trigger and the 24h auto-sweep
// can't run in parallel. Volatile because the ImGui thread reads it outside // can't run in parallel. Volatile because the ImGui thread reads it outside
// the lock to gate the manual button. // the lock to gate the manual button.
@@ -176,8 +189,8 @@ public sealed class Plugin : IAsyncDalamudPlugin
if (Config.Version < 16) if (Config.Version < 16)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"HellionChat v1.4.7 requires config schema v16, got v{Config.Version}. " $"HellionChat v1.4.8 requires config schema v16, got v{Config.Version}. "
+ "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.7." + "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.8."
); );
} }
Config.Version = 17; Config.Version = 17;
@@ -221,6 +234,12 @@ public sealed class Plugin : IAsyncDalamudPlugin
Directory.CreateDirectory(customThemesDir); Directory.CreateDirectory(customThemesDir);
SeedExampleThemeIfEmpty(customThemesDir); SeedExampleThemeIfEmpty(customThemesDir);
ThemeRegistry = new Themes.ThemeRegistry(customThemesDir); ThemeRegistry = new Themes.ThemeRegistry(customThemesDir);
// Warm up the custom-theme cache before the first Switch.
// LoadCustomBySlug is a reverse-lookup over _customCache; on a
// cold cache a Config.Theme that points at a custom slug would
// fall through to the built-in default. AllCustom is a lazy
// enumerable, so iterate it explicitly to materialise the cache.
foreach (var _ in ThemeRegistry.AllCustom()) { }
ThemeRegistry.Switch(Config.Theme); ThemeRegistry.Switch(Config.Theme);
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@@ -282,6 +301,113 @@ public sealed class Plugin : IAsyncDalamudPlugin
if (Interface.Reason is not PluginLoadReason.Boot) if (Interface.Reason is not PluginLoadReason.Boot)
MessageManager.FilterAllTabsAsync(); MessageManager.FilterAllTabsAsync();
// Kick the FTS5 rebuild worker if Migrate4 just added the schema or
// a previous run was cut short (InitFtsReadyCache leaves _ftsReady
// false in that case). Runs off the framework thread on its own
// SqliteConnection so the live UpsertMessage path keeps flowing
// through the chunked-commit windows.
_ftsRebuildCts = new CancellationTokenSource();
if (!MessageManager.Store.IsFtsIndexBuilt)
{
var token = _ftsRebuildCts.Token;
_ = Task.Run(
async () =>
{
// FQN: Plugin.Notification (Z.74) shadows the type name.
Dalamud.Interface.ImGuiNotification.IActiveNotification? notif = null;
try
{
notif = Notification.AddNotification(
new Dalamud.Interface.ImGuiNotification.Notification
{
Title = "Hellion Chat",
Content = "Indexing chat history for full-text search...",
Type = Dalamud
.Interface
.ImGuiNotification
.NotificationType
.Info,
Minimized = false,
InitialDuration = TimeSpan.FromMinutes(10),
}
);
// Progress<T> raises this callback on the captured
// sync-context (Task.Run worker pool). IActiveNotification
// is ImGui-backed and mutates the UI, so marshal the
// mutation onto the framework thread via RunOnTick.
var progress = new Progress<long>(done =>
{
Framework.RunOnTick(() =>
{
if (notif is { } n)
n.Content = $"Indexing chat history: {done:N0} messages...";
});
});
// Worker-owned connection. Closed+disposed before we
// flip the readiness flag so the DbViewer never sees
// IsFtsIndexBuilt=true while the worker connection
// is still alive.
SqliteConnection? workerConn = null;
try
{
workerConn = MessageManager.Store.OpenSecondaryConnection();
var total = await Task.Run(
() =>
MessageManager.Store.RebuildFtsIndex(
workerConn,
progress,
token
),
token
)
.ConfigureAwait(false);
workerConn.Close();
workerConn.Dispose();
workerConn = null;
MessageManager.Store.MarkFtsIndexBuilt();
if (notif is { } final)
{
final.Content = $"Indexed {total:N0} messages.";
final.Type = Dalamud
.Interface
.ImGuiNotification
.NotificationType
.Success;
final.InitialDuration = TimeSpan.FromSeconds(5);
}
}
finally
{
workerConn?.Dispose();
}
}
catch (OperationCanceledException)
{
notif?.DismissNow();
}
catch (Exception ex)
{
Log.Error(ex, "FTS index rebuild failed");
if (notif is { } err)
{
err.Content =
"Full-text indexing failed -- search will use local filter only.";
err.Type = Dalamud
.Interface
.ImGuiNotification
.NotificationType
.Error;
}
}
},
_ftsRebuildCts.Token
);
}
Interface.UiBuilder.DisableCutsceneUiHide = true; Interface.UiBuilder.DisableCutsceneUiHide = true;
Interface.UiBuilder.DisableGposeUiHide = true; Interface.UiBuilder.DisableGposeUiHide = true;
@@ -320,6 +446,12 @@ public sealed class Plugin : IAsyncDalamudPlugin
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
return; return;
// Set before any cleanup so deferred Framework.RunOnTick callbacks
// (B3 retention sweep) see the flag and bail out before they touch
// MessageManager / Log / static fields that the rest of this method
// is about to tear down.
_isDisposing = true;
Exception? failure = null; Exception? failure = null;
// Unsubscribe hooks first — mirrors the hooks-last subscribe order in LoadAsync. // Unsubscribe hooks first — mirrors the hooks-last subscribe order in LoadAsync.
@@ -328,6 +460,19 @@ public sealed class Plugin : IAsyncDalamudPlugin
failure = CaptureFailure(failure, () => Interface.UiBuilder.Draw -= Draw); failure = CaptureFailure(failure, () => Interface.UiBuilder.Draw -= Draw);
failure = CaptureFailure(failure, () => Framework.Update -= FrameworkUpdate); failure = CaptureFailure(failure, () => Framework.Update -= FrameworkUpdate);
// Signal the FTS rebuild worker to bail. Runs before MessageManager
// tears down so the worker's "rebuild failed" log path still finds
// a live Log static. Worker owns its own SqliteConnection and disposes
// it itself; we only flip the cancellation flag here.
failure = CaptureFailure(
failure,
() =>
{
_ftsRebuildCts?.Cancel();
_ftsRebuildCts?.Dispose();
}
);
// Flush a pending DeferredSave — FrameworkUpdate won't fire it anymore. // Flush a pending DeferredSave — FrameworkUpdate won't fire it anymore.
failure = CaptureFailure( failure = CaptureFailure(
failure, failure,
@@ -578,15 +723,31 @@ public sealed class Plugin : IAsyncDalamudPlugin
if (deleted > 0) if (deleted > 0)
{ {
Log.Information($"Retention sweep deleted {deleted} expired messages."); Log.Information($"Retention sweep deleted {deleted} expired messages.");
// Run clear+refilter on the framework thread — FilterAllTabsAsync // Schedule on the next framework tick to avoid the ~194ms
// is fire-and-forget and would race the next sweep cycle. // hitch from blocking with .Wait() while the framework
Framework // finishes the current frame. Tabs-list mutation must
.Run(() => // stay on the framework thread because Plugin.Config.Tabs
// (Configuration.cs:222) is not lock-protected and
// AutoTellTabsService can mutate it from background paths.
// Pattern reference: SimpleTweaks
// Tweaks/Chat/CaseInsensitiveCommands.cs:45.
Framework.RunOnTick(() =>
{
// The retention thread is IsBackground=true so plugin
// unload can fire while a scheduled tick is still
// pending; bail before touching anything torn down.
if (_isDisposing)
return;
try
{ {
MessageManager.ClearAllTabs(); MessageManager.ClearAllTabs();
MessageManager.FilterAllTabs(); MessageManager.FilterAllTabs();
}) }
.Wait(); catch (Exception ex)
{
Log.Error(ex, "Retention sweep clear+refilter failed");
}
});
} }
else else
{ {
@@ -610,6 +771,12 @@ public sealed class Plugin : IAsyncDalamudPlugin
private void Draw() private void Draw()
{ {
// v1.4.8 B2: pick up external edits of the active custom theme JSON
// without forcing the user to re-click the picker. The disk-stat is
// 1Hz-throttled inside RefreshActiveIfStale, so this is essentially
// free on built-in themes and ~1 stat/second on custom themes.
ThemeRegistry.RefreshActiveIfStale();
// Theme engine is always active; Classic is a theme, not a disabled state. // Theme engine is always active; Classic is a theme, not a disabled state.
using IDisposable _style = HellionStyle.PushGlobal( using IDisposable _style = HellionStyle.PushGlobal(
ThemeRegistry.Active, ThemeRegistry.Active,
+5
View File
@@ -402,4 +402,9 @@ internal class HellionStrings
// Hellion Chat — v1.3.0 Honorific title slot tooltip // Hellion Chat — v1.3.0 Honorific title slot tooltip
internal static string ChatHeader_HonorificTitle_Tooltip => Get(nameof(ChatHeader_HonorificTitle_Tooltip)); internal static string ChatHeader_HonorificTitle_Tooltip => Get(nameof(ChatHeader_HonorificTitle_Tooltip));
// Hellion Chat — v1.4.8 DbViewer full-text search toggle
internal static string DbViewer_FullTextToggle => Get(nameof(DbViewer_FullTextToggle));
internal static string DbViewer_FullTextToggle_Hint_Indexing => Get(nameof(DbViewer_FullTextToggle_Hint_Indexing));
internal static string DbViewer_FullTextToggle_Hint_PhraseMode => Get(nameof(DbViewer_FullTextToggle_Hint_PhraseMode));
} }
@@ -917,4 +917,13 @@
<data name="ChatHeader_HonorificTitle_Tooltip" xml:space="preserve"> <data name="ChatHeader_HonorificTitle_Tooltip" xml:space="preserve">
<value>Custom-Titel von Honorific</value> <value>Custom-Titel von Honorific</value>
</data> </data>
<data name="DbViewer_FullTextToggle" xml:space="preserve">
<value>Volltext-Suche</value>
</data>
<data name="DbViewer_FullTextToggle_Hint_Indexing" xml:space="preserve">
<value>Der Volltext-Index wird noch gebaut. Die lokale Suche bleibt verfügbar.</value>
</data>
<data name="DbViewer_FullTextToggle_Hint_PhraseMode" xml:space="preserve">
<value>Sucht nach der exakten Wortfolge. Mehrere Wörter werden nur gefunden, wenn sie zusammen und in dieser Reihenfolge stehen. Wer rohe FTS5-MATCH-Syntax nutzen will, setzt eigene Anführungszeichen um den Suchbegriff.</value>
</data>
</root> </root>
@@ -917,4 +917,13 @@
<data name="ChatHeader_HonorificTitle_Tooltip" xml:space="preserve"> <data name="ChatHeader_HonorificTitle_Tooltip" xml:space="preserve">
<value>Custom title from Honorific</value> <value>Custom title from Honorific</value>
</data> </data>
<data name="DbViewer_FullTextToggle" xml:space="preserve">
<value>Full-text search</value>
</data>
<data name="DbViewer_FullTextToggle_Hint_Indexing" xml:space="preserve">
<value>The full-text index is still being built. The local filter remains available.</value>
</data>
<data name="DbViewer_FullTextToggle_Hint_PhraseMode" xml:space="preserve">
<value>Searches for the exact phrase. Multi-word queries match only when the words appear together in order. To use raw FTS5 MATCH syntax, wrap your term in double quotes yourself.</value>
</data>
</root> </root>
+99 -11
View File
@@ -6,6 +6,13 @@ public sealed class ThemeRegistry
{ {
public const string DefaultSlug = HellionArctic.Slug; public const string DefaultSlug = HellionArctic.Slug;
// 1Hz throttle for the v1.4.8 B2 auto-refresh-on-active path. The
// Plugin.Draw hook calls RefreshActiveIfStale every frame, but the
// actual File.GetLastWriteTimeUtc disk-stat only runs once per second
// -- 60fps would otherwise mean 3600 stats/min on the same path (more
// on Wine). Same idiom as the StatusBar 1Hz cache.
private const long ActiveStampPollIntervalMs = 1000;
private readonly Dictionary<string, Theme> _builtIns; private readonly Dictionary<string, Theme> _builtIns;
private readonly Dictionary<string, (Theme Theme, DateTime Stamp)> _customCache = new( private readonly Dictionary<string, (Theme Theme, DateTime Stamp)> _customCache = new(
StringComparer.OrdinalIgnoreCase StringComparer.OrdinalIgnoreCase
@@ -13,6 +20,15 @@ public sealed class ThemeRegistry
private readonly string? _customThemesDir; private readonly string? _customThemesDir;
private Theme _active; private Theme _active;
// v1.4.8 B2: source path of the currently active custom theme. Captured
// at Switch() time so RefreshActiveIfStale does not have to reconstruct
// a filename from the slug -- custom theme filenames are not required
// to match the slug they declare in the JSON body. Null when the active
// theme is built-in or no custom-themes directory is configured.
private string? _activeCustomPath;
private long _lastActiveStampCheckMs = -ActiveStampPollIntervalMs;
private DateTime _lastActiveStamp = DateTime.MinValue;
public ThemeRegistry(string? customThemesDir = null) public ThemeRegistry(string? customThemesDir = null)
{ {
// Insertion order drives the Theme-Picker grid layout (3 columns). // Insertion order drives the Theme-Picker grid layout (3 columns).
@@ -48,7 +64,9 @@ public sealed class ThemeRegistry
if (_builtIns.TryGetValue(slug, out var b)) if (_builtIns.TryGetValue(slug, out var b))
return b; return b;
var custom = LoadCustomBySlug(slug); // Discard the source path here; Switch is the only call-site that
// needs to remember it for the auto-refresh hook.
var custom = LoadCustomBySlug(slug, out _);
if (custom != null) if (custom != null)
return custom; return custom;
@@ -59,12 +77,70 @@ public sealed class ThemeRegistry
public IEnumerable<Theme> AllCustom() => RefreshCustomCache(); public IEnumerable<Theme> AllCustom() => RefreshCustomCache();
// Built-in-first to match Get(slug)'s lookup order. A user theme JSON
// that declares the same slug as a built-in is ignored deliberately --
// having Switch prefer custom and Get prefer built-in would produce
// a state where _active and Get(_active.Slug) disagree.
public void Switch(string slug) public void Switch(string slug)
{ {
var theme = Get(slug); if (_builtIns.TryGetValue(slug, out var builtin))
// Defensive — ensures any future theme source always gets a populated cache. {
theme.RecomputeAbgrCache(); _active = builtin;
_active = theme; _active.RecomputeAbgrCache();
_activeCustomPath = null;
return;
}
var customTheme = LoadCustomBySlug(slug, out var customPath);
if (customTheme is not null)
{
_active = customTheme;
// Defensive — ensures any future theme source always gets a populated cache.
_active.RecomputeAbgrCache();
_activeCustomPath = customPath;
// Force a first-tick reload-check after the switch so the stamp
// baseline is established on the next RefreshActiveIfStale call.
_lastActiveStamp = DateTime.MinValue;
return;
}
// Fallback: neither built-in nor custom matched. Drop to default
// and clear the active custom path so RefreshActiveIfStale stays idle.
_active = _builtIns[DefaultSlug];
_active.RecomputeAbgrCache();
_activeCustomPath = null;
}
// 1Hz-throttled disk-stat on the currently active custom theme file.
// When the file's LastWriteTime moves forward (editor save), reload the
// theme via Get() so the user sees the edit immediately without
// re-selecting in the picker. Built-in themes short-circuit; custom
// themes without an _activeCustomPath (e.g. Switch fell to default)
// short-circuit too.
public void RefreshActiveIfStale()
{
var now = Environment.TickCount64;
if (now - _lastActiveStampCheckMs < ActiveStampPollIntervalMs)
return;
_lastActiveStampCheckMs = now;
if (_active.IsBuiltIn)
return;
var path = _activeCustomPath;
if (path is null || !File.Exists(path))
return;
var stamp = File.GetLastWriteTimeUtc(path);
if (!ThemeStampDiff.IsStale(_lastActiveStamp, stamp))
return;
_lastActiveStamp = stamp;
// Get() re-runs RefreshCustomCache which picks up the new content
// (the cache keys by path + LastWriteTime, so a mtime bump invalidates).
// RecomputeAbgrCache happens inside RefreshCustomCache on cache miss.
var reloaded = Get(_active.Slug);
_active = reloaded;
} }
// 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION. // 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION.
@@ -77,18 +153,30 @@ public sealed class ThemeRegistry
return code == 0x80070020u || code == 0x80070021u; return code == 0x80070020u || code == 0x80070021u;
} }
// Custom themes are loaded lazily, cached by LastWriteTime. // Slug -> Theme lookup with the source path as an out-param so the
// A changed JSON is reloaded on the next lookup. // Switch path can remember which file backs the active custom theme.
private Theme? LoadCustomBySlug(string slug) // Pure reverse-lookup over the existing _customCache: that cache is
// already Path -> (Theme, Stamp), so iterating it costs nothing,
// avoids a re-parse of every JSON, and keeps the parse logic (and
// the recoverable-file-lock recovery) confined to RefreshCustomCache.
// The cache must be warm before this runs; Plugin.LoadAsync triggers
// a one-time warm-up via AllCustom() before the first Switch call.
private Theme? LoadCustomBySlug(string slug, out string? sourcePath)
{ {
sourcePath = null;
if (_customThemesDir is null) if (_customThemesDir is null)
return null; return null;
if (!Directory.Exists(_customThemesDir)) if (!Directory.Exists(_customThemesDir))
return null; return null;
foreach (var theme in RefreshCustomCache()) foreach (var kvp in _customCache)
if (string.Equals(theme.Slug, slug, StringComparison.OrdinalIgnoreCase)) {
return theme; if (string.Equals(kvp.Value.Theme.Slug, slug, StringComparison.OrdinalIgnoreCase))
{
sourcePath = kvp.Key;
return kvp.Value.Theme;
}
}
return null; return null;
} }
+20
View File
@@ -0,0 +1,20 @@
namespace HellionChat.Themes;
// Pure stale-check for the v1.4.8 B2 theme-auto-refresh-on-active path.
// Lives in a free helper class so the Build-Suite can exercise the diff
// rules without instantiating ThemeRegistry (which touches the Dalamud
// log proxy and the filesystem). The rules:
// - DateTime.MinValue on the current stat means we could not read the
// file -- hold the last known good (return false).
// - Equal stamps mean no change since we last saw it.
// - Any other difference, including the first observation where lastSeen
// is MinValue, counts as stale and triggers a reload.
internal static class ThemeStampDiff
{
public static bool IsStale(System.DateTime lastSeen, System.DateTime current)
{
if (current == System.DateTime.MinValue)
return false;
return current != lastSeen;
}
}
+3 -2
View File
@@ -419,8 +419,9 @@ public sealed class ChatLogWindow : Window
// The hint banner renders before this block so ImGui already accounts for it. // The hint banner renders before this block so ImGui already accounts for it.
height -= ImGui.GetFrameHeightWithSpacing(); height -= ImGui.GetFrameHeightWithSpacing();
// Status bar at the window bottom reserves 22px + 2px spacing. // StatusBar.Height now bakes in its own DPI-aware 2px spacer, so the
height -= StatusBar.Height + 2; // window reservation is just Height -- no extra +2 (v1.4.8 B1).
height -= StatusBar.Height;
return height; return height;
} }
+72 -1
View File
@@ -2,6 +2,7 @@
using System.Globalization; using System.Globalization;
using System.Numerics; using System.Numerics;
using System.Text; using System.Text;
using System.Threading;
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface; using Dalamud.Interface;
@@ -33,11 +34,21 @@ public class DbViewer : Window
private int CurrentPage = 1; private int CurrentPage = 1;
private string SimpleSearchTerm = ""; private string SimpleSearchTerm = "";
// v1.4.8 H2: opt-in full-text search across the whole DB via FTS5.
// Transient UI state (per-session), not persisted -- users opt in fresh
// every time so they always see the page-filter as the default mode.
private bool UseFullTextSearch;
private bool OnlyCurrentCharacter = true; private bool OnlyCurrentCharacter = true;
private readonly Dictionary<ChatType, (ChatSource, ChatSource)> SelectedChannels; private readonly Dictionary<ChatType, (ChatSource, ChatSource)> SelectedChannels;
private bool IsProcessing; private bool IsProcessing;
private long ProcessingStart = Environment.TickCount64; private long ProcessingStart = Environment.TickCount64;
// Bumped per trigger so a late worker drops itself instead of overwriting
// a newer result.
private long _ftsFilterSeq;
private (DateTime Min, DateTime Max, int Page, bool Local, int ChannelCount) LastProcessed; private (DateTime Min, DateTime Max, int Page, bool Local, int ChannelCount) LastProcessed;
private string MinDateString = ""; private string MinDateString = "";
@@ -233,6 +244,24 @@ public class DbViewer : Window
tooltipRight: Language.Page_ArrowRight_Tooltip tooltipRight: Language.Page_ArrowRight_Tooltip
); );
// Full-text search toggle (v1.4.8 H2). IsFtsIndexBuilt is a cached
// volatile bool in MessageStore -- single field read per frame, no
// SELECT count(*). ImRaii.Disabled blocks any click while the index
// is still being built, so no defensive force-off branch needed
// inside the if-body. UseFullTextSearch is transient UI state, so we
// do not call SaveConfig here.
var ftsReady = Plugin.MessageManager.Store.IsFtsIndexBuilt;
using (ImRaii.Disabled(!ftsReady))
{
if (ImGui.Checkbox(HellionStrings.DbViewer_FullTextToggle, ref UseFullTextSearch))
TriggerFilterRefresh();
}
ImGuiUtil.HelpMarker(
ftsReady
? HellionStrings.DbViewer_FullTextToggle_Hint_PhraseMode
: HellionStrings.DbViewer_FullTextToggle_Hint_Indexing
);
ImGui.SameLine(ImGui.GetContentRegionMax().X - width); ImGui.SameLine(ImGui.GetContentRegionMax().X - width);
ImGui.SetNextItemWidth(width); ImGui.SetNextItemWidth(width);
if ( if (
@@ -243,7 +272,7 @@ public class DbViewer : Window
30 30
) )
) )
Filtered = Filter(Messages); TriggerFilterRefresh();
// Third row // Third row
@@ -447,11 +476,53 @@ public class DbViewer : Window
} }
} }
// FTS path hits SQLite per keystroke -- dispatch to a worker, drop stale
// results via _ftsFilterSeq. Page-filter path is in-memory LINQ, stays
// inline.
private void TriggerFilterRefresh()
{
if (!UseFullTextSearch || !Plugin.MessageManager.Store.IsFtsIndexBuilt)
{
Filtered = Filter(Messages);
return;
}
var snapshot = Messages;
var mySeq = Interlocked.Increment(ref _ftsFilterSeq);
Task.Run(() =>
{
try
{
var result = Filter(snapshot);
if (Interlocked.Read(ref _ftsFilterSeq) == mySeq)
Filtered = result;
}
catch (Exception ex)
{
Plugin.LogProxy.Error(ex, "FTS filter worker failed");
}
});
}
private ConcurrentStack<Message> Filter(Message[] messages) private ConcurrentStack<Message> Filter(Message[] messages)
{ {
if (SimpleSearchTerm == "") if (SimpleSearchTerm == "")
return new ConcurrentStack<Message>(messages.Reverse().OrderByDescending(m => m.Date)); return new ConcurrentStack<Message>(messages.Reverse().OrderByDescending(m => m.Date));
// Full-text mode bypasses the page-bounded messages array and queries
// the FTS5 index across the whole DB. IsFtsIndexBuilt re-check guards
// against the (rare) case of the toggle being on while the index is
// mid-rebuild -- ImRaii.Disabled prevents the user from flipping it,
// but a Dispose-and-reopen during indexing could leave UseFullTextSearch
// true while ftsReady flipped back to false; the local fallback below
// still serves the page.
if (UseFullTextSearch && Plugin.MessageManager.Store.IsFtsIndexBuilt)
{
var guidHits = Plugin.MessageManager.Store.FullTextSearch(SimpleSearchTerm);
var resolved = Plugin.MessageManager.Store.LoadByGuids(guidHits);
return new ConcurrentStack<Message>(resolved.OrderByDescending(m => m.Date));
}
return new ConcurrentStack<Message>( return new ConcurrentStack<Message>(
messages messages
.Reverse() .Reverse()
+13 -4
View File
@@ -2,6 +2,7 @@ using System.Globalization;
using System.Numerics; using System.Numerics;
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;
using HellionChat.Code; using HellionChat.Code;
using HellionChat.Resources; using HellionChat.Resources;
@@ -9,12 +10,20 @@ using HellionChat.Util;
namespace HellionChat.Ui; namespace HellionChat.Ui;
// Bottom status bar, 22px tall. Slots left to right: channel indicator, // Bottom status bar. Slots left to right: channel indicator, privacy badge,
// privacy badge, counts, tells (hidden at 0), version (right-aligned). // counts, tells (hidden at 0), version (right-aligned). Updates at 1Hz;
// Updates at 1Hz; format strings are cached between updates. // format strings are cached between updates.
internal sealed class StatusBar internal sealed class StatusBar
{ {
public const float Height = 22f; // DPI-aware bar height. The previous fixed 22px constant clipped on
// Windows display-scaling >100% because ImGui renders the font bigger
// than the reservation. GetTextLineHeightWithSpacing scales with the
// current ImGui font; the 2px spacer is GlobalScale-rounded to stay
// on integer pixel boundaries (same idiom as v1.4.6 F7.2 underline-pill
// in ChatLogWindow.cs:1639-1653).
public static float Height =>
ImGui.GetTextLineHeightWithSpacing() + MathF.Round(2f * ImGuiHelpers.GlobalScale);
private const long UpdateIntervalMs = 1000; private const long UpdateIntervalMs = 1000;
// Initially outdated so the first frame always computes fresh. // Initially outdated so the first frame always computes fresh.
+15 -19
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.7-brightgreen)](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/latest) [![Latest release](https://img.shields.io/badge/release-v1.4.8-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.7** — Privacy-first chat plugin for FINAL FANTASY XIV / Dalamud, built on **Version 1.4.8** — 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,23 +286,19 @@ An optional submission to the Dalamud main plugin repo (in addition to the custo
## Project Status ## Project Status
**Version 1.4.7**Backlog cleanup and the first user-visible feature bundle since v1.4.5. TempTell tabs can now be **Version 1.4.8**Hook-Layer and Polish Quick-Wins. The Database Viewer now has an optional FTS5 full-text search
pinned via right-click; pinned tabs survive relog, keep their conversation history (loaded on demand from the message across the entire chat history. Toggle "Full-text search" next to the search bar; the index is built asynchronously on
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 first run after the update with a progress toast, and the toggle stays disabled until the build completes. Multi-word
auto-tell pool, so the total ceiling is 20 tabs. The sidebar groups pinned tabs into their own section with its own terms match as exact phrases by default; power users can opt into raw FTS5 `MATCH` syntax by wrapping their own
divider header. Honorific glow outlines now render when the title carries a Glow colour — opt-in via **Settings → double-quotes. Custom theme files auto-reload when edited while the theme is active — save the JSON in your editor and
Integrations → Render glow outlines (Honorific)**, default off, so v1.4.6 visuals stay untouched for users who don't the live render picks up the change within a second, no picker click. Retention sweep no longer blocks the framework
care and the per-frame DrawList overhead is skipped on low-end hardware. Honorific gradient (Color3 / GradientColourSet thread (`Framework.Run(...).Wait()` replaced by `Framework.RunOnTick(...)`), removing the ~194 ms hitch per sweep. Status
/ Wave / Pulse) is parsed and stashed for a later cycle, but currently renders as the primary colour. Sidebar width is bar height is now derived from `GetTextLineHeightWithSpacing()` plus a DPI-aware spacer so the bar renders correctly at
configurable in **Theme & Layout** between 44 and 160 px; default stays icon-only so existing users see no layout Windows display scaling above 100 %. Receive-suppressed-tells routing is postponed to v1.5.x; the investigation in this
change. `Configuration.UpdateFrom` now preserves the runtime `CurrentChannel` across the persistent-tab merge, and cycle showed that the FFXIV `ContentIdResolverHook` does not fire when other plugins suppress tells via
`TabSwitched` deep-clones the seeded channel — together they fix a Settings-Save regression where the chat input could `CheckMessageHandled`, which means tell-partner identification breaks for AutoTellTab routing — the fix lives next to
pop back to `/tell <pinned-partner>` after touching settings while on a Party or Linkshell tab. Internal items: the planned ad-block hook layer where the same `RaptureLogModule` patch surface comes up anyway. Migration v17 stays
`IPluginLogProxy` indirection over Dalamud's `IPluginLog` routes all ~91 `Plugin.Log` call sites through a testable (no schema bump). Ninth sub-patch of the v1.4.x polish sweep series (as of 2026-05-14).
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:
+29
View File
@@ -10,6 +10,35 @@ to the release pages for details.
--- ---
## Hellion Chat 1.4.8 — Hook-Layer and Polish Quick-Wins (2026-05-14)
Ninth sub-patch of the v1.4.x polish-sweep series. Hook-layer cluster (FTS5 full-text search, ad-block foundation
investigation) plus three polish quick-wins.
- DbViewer full-text search: optional FTS5 index across the full chat history. Built asynchronously on first run after
the update with a progress toast (UI stays responsive, the toggle is disabled until the build completes). The local
page-filter remains the default mode. Multi-word queries match as exact phrases; power users can opt into raw FTS5
`MATCH` syntax by wrapping their own double-quotes around the term.
- Custom theme files now auto-reload when edited while the theme is active. Save the JSON in your editor and the live
render picks up the change within a second — no need to re-click the theme in the picker. Disk-stat is throttled to
1 Hz so per-frame cost stays free.
- Retention sweep no longer blocks the framework thread. `Framework.Run(...).Wait()` is replaced by
`Framework.RunOnTick(...)`, which removes the ~194 ms hitch the sweep used to add per run.
- Status bar height is derived from `GetTextLineHeightWithSpacing()` plus a DPI-aware spacer so the bar renders
correctly at Windows display scaling above 100 %. Linux/Wayland default of 100 % is unaffected.
- Receive-suppressed-tells routing was investigated this cycle and **postponed to v1.5.x**. When other plugins suppress
tells via `CheckMessageHandled`, FFXIV's chat pipeline skips the `RaptureLogModule.AddMsgSourceEntry` path, which means
HellionChat's `ContentIdResolverHook` does not fire and tell-partner identification breaks for AutoTellTab routing.
The proper fix sits next to the planned ad-block hook layer (`RaptureLogModule.ShowMiniTalkPlayer` and friends) where
the same patch surface comes up anyway.
- Internal: storage form of `messages.Id` clarified (declared BLOB but Microsoft.Data.Sqlite stores Guid parameters as
TEXT). FTS bulk insert and `LoadByGuids` join now match the TEXT storage form on both sides. Migration v17 stays
(no schema bump).
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
---
## Hellion Chat 1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13) ## 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 Eighth sub-patch of the v1.4.x polish-sweep series. First user-visible feature bundle since v1.4.5 — pinned tell tabs
+21 -4
View File
@@ -10,11 +10,28 @@ the plugin's privacy-first scope during brainstorming.
--- ---
## Next Cycle (v1.4.8) ## Next Cycle (v1.4.9)
**Hook-Layer Cycle.** Receive-suppressed-tells toggle (cross-reference XIVIM #73 bubble-layer sub-task), Database Viewer **Plugin-Load Render Polish.** Erststart-Frame-Hitch (~110 ms UiBuilder) and the related Font-Atlas + Auto-Translate
full-text search via SQLite FTS5, plus preparation for the later Ad-Block cycle. Hook-layer investigation is shared warmup costs surface every load and are reproducible in `/xlstats`. The cycle also unblocks the lazy-window refactor
across these items so they cluster naturally in one sub-patch. sketched in `feedback_lazy_window_dalamud` and the slash-command centralisation that comes with it.
---
## v1.4.8 — Hook-Layer and Polish Quick-Wins (released 2026-05-14)
Ninth sub-patch of the v1.4.x Polish Sweep series. Database Viewer gains an optional FTS5 full-text search across the
full chat history, built asynchronously on first run after the update with a progress toast; the local page-filter
remains the default mode. Custom theme files auto-reload when edited while the theme is active (1 Hz disk-stat throttle,
so per-frame cost is free). Retention sweep no longer blocks the framework thread — `Framework.Run(...).Wait()` is
replaced by `Framework.RunOnTick(...)`, removing the ~194 ms hitch per sweep. Status-bar height is now derived from
`GetTextLineHeightWithSpacing()` plus a DPI-aware spacer so the bar renders correctly at Windows display scaling above
100 %. Receive-suppressed-tells routing was investigated and **postponed to v1.5.x**: when other plugins suppress tells
via `CheckMessageHandled`, FFXIV's chat-pipeline skips the `RaptureLogModule.AddMsgSourceEntry` path, which means the
`ContentIdResolverHook` does not fire and tell-partner identification breaks. The fix belongs next to the planned ad-block
hook layer where the same patch surface comes up anyway. Migration v17 stays (no schema bump). H3 leaves a foundation
note in the Vault (`Projekte/FFXIV/Hellion Chat/v1.5.x Ad-Block Foundation.md`) covering the NoSoliciting filter +
bubble-layer hook pattern as a ready-made template for the v1.5.x cycle.
--- ---
+6 -6
View File
File diff suppressed because one or more lines are too long