From 67175419a91367e1784524fd8ad9b7908e5ad59d Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 13 May 2026 19:20:55 +0200 Subject: [PATCH 01/13] refactor(messagestore): extract ReadMessageRow as shared deserialiser Pure deserialisation helper that pulls one row from the current reader position into a Message. The MessageEnumerator load path delegates to it, and the upcoming FTS-join LoadByGuids (Task 4.3) will share the same code so both stay in lockstep when the column layout shifts. Pre-step for v1.4.8 H2 FTS5 full-text search. --- HellionChat/MessageStore.cs | 68 ++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index 8a23567..a1e1851 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -136,6 +136,43 @@ internal class MessageStore : IDisposable ) ); + // Pure deserialisation of one messages-row at the reader's current position. + // Shared between the MessageEnumerator load path and the upcoming v1.4.8 + // LoadByGuids FTS-join path so both stay in lockstep when the column layout + // moves. Throws on row-level errors; the caller decides whether to skip+log + // (enumerator) or fail-fast (bulk lookup). + internal static Message ReadMessageRow(DbDataReader reader) + { + return new Message( + reader.GetGuid(0), + (ulong)reader.GetInt64(1), + (ulong)reader.GetInt64(2), + DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(3)), + new ChatCode( + (byte)reader.GetInt32(4), + (byte)reader.GetInt32(5), + (byte)reader.GetInt32(6) + ), + MessagePackSerializer.Deserialize>( + reader.GetFieldValue(7), + MsgPackOptions + ), + MessagePackSerializer.Deserialize>( + reader.GetFieldValue(8), + MsgPackOptions + ), + MessagePackSerializer.Deserialize( + reader.GetFieldValue(9), + MsgPackOptions + ), + MessagePackSerializer.Deserialize( + reader.GetFieldValue(10), + MsgPackOptions + ), + reader.GetGuid(11) + ); + } + private readonly IPlatformUtil _platformUtil; private readonly IPluginLogProxy _logger; @@ -816,35 +853,10 @@ internal class MessageEnumerator(DbDataReader reader, IPluginLogProxy logger) Message msg; try { + // GetGuid up-front so we have an id for the failure log even + // when the rest of the deserialisation throws downstream. id = reader.GetGuid(0); - msg = new Message( - id, - (ulong)reader.GetInt64(1), - (ulong)reader.GetInt64(2), - DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(3)), - new ChatCode( - (byte)reader.GetInt32(4), - (byte)reader.GetInt32(5), - (byte)reader.GetInt32(6) - ), - MessagePackSerializer.Deserialize>( - reader.GetFieldValue(7), - MessageStore.MsgPackOptions - ), - MessagePackSerializer.Deserialize>( - reader.GetFieldValue(8), - MessageStore.MsgPackOptions - ), - MessagePackSerializer.Deserialize( - reader.GetFieldValue(9), - MessageStore.MsgPackOptions - ), - MessagePackSerializer.Deserialize( - reader.GetFieldValue(10), - MessageStore.MsgPackOptions - ), - reader.GetGuid(11) - ); + msg = MessageStore.ReadMessageRow(reader); } catch (Exception e) { From 38149059c3a5cf1dccf604922d518e89a8fedd60 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 13 May 2026 19:51:54 +0200 Subject: [PATCH 02/13] feat(messagestore): add Migrate4 with standalone FTS5 virtual table Lays down a messages_fts virtual table with message_guid (UNINDEXED, hex TEXT of the BLOB primary key), sender_text and content_text columns using the unicode61 tokenizer with diacritic folding. Standalone FTS5 without content='messages' linking, because messages.Id is BLOB and FTS5's content_rowid contract requires an INTEGER rowid alias. LoadByGuids (Task 4.3) will resolve the hex GUIDs back to messages rows via WHERE Id IN (...) joins. Schema step only -- the bulk-insert worker that fills the index lives in Task 4.2. Internal Connection property exposure plus a HasMessagesFtsTable helper let the Build-Suite verify Migrate4 without raw PRAGMA glue in each test. v1.4.8 H2 Sub-Task 4.1. --- HellionChat/MessageStore.cs | 47 ++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index a1e1851..9aa3bfe 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -126,7 +126,12 @@ internal class MessageStore : IDisposable private const int MessageQueryLimit = 10_000; private string DbPath { get; } - private SqliteConnection Connection { get; set; } + + // Internal so the Build-Suite tests can verify Migrate4's CREATE VIRTUAL + // TABLE result via a one-off PRAGMA without exposing a dedicated helper + // for each schema invariant. Setter stays private; the ctor is the only + // place that assigns. + internal SqliteConnection Connection { get; private set; } internal static readonly MessagePackSerializerOptions MsgPackOptions = MessagePackSerializerOptions.Standard.WithResolver( @@ -227,13 +232,19 @@ internal class MessageStore : IDisposable migrationsToDo.Add(Migrate1); migrationsToDo.Add(Migrate2); migrationsToDo.Add(Migrate3); + migrationsToDo.Add(Migrate4); break; case 1: migrationsToDo.Add(Migrate2); migrationsToDo.Add(Migrate3); + migrationsToDo.Add(Migrate4); break; case 2: migrationsToDo.Add(Migrate3); + migrationsToDo.Add(Migrate4); + break; + case 3: + migrationsToDo.Add(Migrate4); break; } @@ -344,6 +355,30 @@ internal class MessageStore : IDisposable SetMigrationVersion(3); } + private void Migrate4() + { + _logger.Information("Running migration 4: Add FTS5 virtual table for full-text search"); + + // Standalone FTS5 table (no content='messages' linking, no content_rowid). + // messages.Id is BLOB-PK (Guid), which is incompatible with FTS5's + // content_rowid requirement of an INTEGER rowid alias. We store the + // GUID as a hex TEXT column (UNINDEXED so the tokenizer skips it) and + // FTS5 manages its own internal INTEGER rowid. LoadByGuids joins back + // via WHERE Id IN (... unhex(message_guid)) when the search returns. + using var cmd = Connection.CreateCommand(); + cmd.CommandText = """ + CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + message_guid UNINDEXED, + sender_text, + content_text, + tokenize='unicode61 remove_diacritics 2' + ); + """; + cmd.ExecuteNonQuery(); + + SetMigrationVersion(4); + } + private void SetMigrationVersion(int version) { _logger.Information($"Setting version {version}"); @@ -493,6 +528,16 @@ internal class MessageStore : IDisposable return Convert.ToInt32(cmd.ExecuteScalar()); } + // Schema probe for the v1.4.8 FTS5 virtual table. Used by the Build-Suite + // tests to verify Migrate4's CREATE VIRTUAL TABLE actually landed without + // duplicating PRAGMA glue in each test body. + internal bool HasMessagesFtsTable() + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE name='messages_fts';"; + return (long)(cmd.ExecuteScalar() ?? 0L) > 0; + } + internal void UpsertMessage(Message message) { // Privacy filter -- drop disallowed ChatTypes before they reach storage. From 7f317a2b18c92e7be80eb866ee643b50b3fae32f Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 13 May 2026 20:31:43 +0200 Subject: [PATCH 03/13] refactor(messagestore): extract BuildConnectionString and ApplyPragmas helpers Pre-step for the v1.4.8 FTS5 bulk-insert worker. The worker opens its own secondary SqliteConnection on the same db path so the WAL journal lets parallel reads/writes through, and it has to apply the exact same connection-string options and PRAGMAs as Connect() -- otherwise the worker connection drifts the moment Connect grows a new pragma. Splitting BuildConnectionString + ApplyPragmas out lets both Connect() and the upcoming OpenSecondaryConnection() share the same source of truth instead of duplicating the body. No behaviour change. --- HellionChat/MessageStore.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index 9aa3bfe..fd4378d 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -198,22 +198,31 @@ internal class MessageStore : IDisposable Connection.Dispose(); } - private SqliteConnection Connect() + private static string BuildConnectionString(string dbPath) { var uriBuilder = new SqliteConnectionStringBuilder { - DataSource = DbPath, + DataSource = dbPath, DefaultTimeout = 5, Pooling = false, Mode = SqliteOpenMode.ReadWriteCreate, }; + return uriBuilder.ToString(); + } - var conn = new SqliteConnection(uriBuilder.ToString()); - conn.Open(); + private void ApplyPragmas(SqliteConnection conn) + { conn.Execute(@"PRAGMA journal_mode=WAL;"); conn.Execute(@"PRAGMA synchronous=NORMAL;"); if (_platformUtil.IsWine) conn.Execute(@"PRAGMA cache_size = 32768;"); + } + + private SqliteConnection Connect() + { + var conn = new SqliteConnection(BuildConnectionString(DbPath)); + conn.Open(); + ApplyPragmas(conn); return conn; } From d26c4701fad48a011be1f22b87e96929cb25027d Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 13 May 2026 20:38:05 +0200 Subject: [PATCH 04/13] feat(messagestore): async background FTS5 bulk-insert with progress notification Adds the worker that fills the messages_fts virtual table after Migrate4. The bulk-insert runs off the framework thread on its own SqliteConnection opened via OpenSecondaryConnection -- WAL lets the live UpsertMessage path on the primary Connection keep flowing, and the worker's writer lock yields every 500 rows with a 5ms breather so PendingMessageThread does not hit "database is locked" after DefaultTimeout=5s. InitFtsReadyCache runs in the ctor and short-circuits to ready=true when the index is already populated or when the messages table is empty. The DbViewer (Task 4.4) reads IsFtsIndexBuilt per frame as a single volatile field read. Plugin.cs LoadAsync kicks the worker after FilterAllTabsAsync, gated on IsFtsIndexBuilt and a CancellationTokenSource that DisposeAsync cancels before MessageManager tears down. Progress reports back via IActiveNotification, marshalled onto the framework thread via Framework.RunOnTick. Success path finishes the notification as Success with a 5s linger; cancellation dismisses it; an error swaps the type to Error with a fallback hint. --- HellionChat/MessageStore.cs | 158 ++++++++++++++++++++++++++++++++++++ HellionChat/Plugin.cs | 127 +++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index fd4378d..eaea976 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -181,6 +181,14 @@ internal class MessageStore : IDisposable private readonly IPlatformUtil _platformUtil; private readonly IPluginLogProxy _logger; + // Readiness gate for the FTS5 full-text index. Volatile so the DbViewer's + // per-frame IsFtsIndexBuilt read sees the flip the moment the bulk-insert + // worker calls MarkFtsIndexBuilt(). Set in the ctor by InitFtsReadyCache: + // true when the index already has rows (no rebuild needed) or when the + // messages table itself is empty (nothing to index yet); false otherwise. + private volatile bool _ftsReady; + public bool IsFtsIndexBuilt => _ftsReady; + internal MessageStore(string dbPath, IPlatformUtil platformUtil, IPluginLogProxy logger) { DbPath = dbPath; @@ -188,6 +196,7 @@ internal class MessageStore : IDisposable _logger = logger; Connection = Connect(); Migrate(); + InitFtsReadyCache(); } public void Dispose() @@ -547,6 +556,155 @@ internal class MessageStore : IDisposable return (long)(cmd.ExecuteScalar() ?? 0L) > 0; } + // Decides whether the FTS index already covers the messages table. Called + // once after Migrate -- empty messages-table is "ready" because there is + // nothing to index yet; a populated fts-table is "ready" because some + // previous run filled it. A populated messages-table with an empty + // fts-table is the "needs rebuild" case the worker (Plugin.cs LoadAsync) + // picks up. + internal void InitFtsReadyCache() + { + using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "SELECT count(*) FROM messages_fts;"; + var ftsRows = (long)(cmd.ExecuteScalar() ?? 0L); + if (ftsRows > 0) + { + _ftsReady = true; + return; + } + } + + using var cmd2 = Connection.CreateCommand(); + cmd2.CommandText = "SELECT count(*) FROM messages;"; + var messageRows = (long)(cmd2.ExecuteScalar() ?? 0L); + _ftsReady = messageRows == 0; + } + + // Opens a worker-owned SqliteConnection on the same db path. Used by the + // FTS rebuild worker so the bulk-insert writer stream does not contend + // with the live UpsertMessage path on the primary Connection (WAL allows + // N readers + 1 writer; two writer sessions on the same connection are + // not safe per Microsoft.Data.Sqlite). Caller closes+disposes the + // returned connection and only then calls MarkFtsIndexBuilt() -- the + // DbViewer never sees IsFtsIndexBuilt=true while the worker connection + // is still alive. + internal SqliteConnection OpenSecondaryConnection() + { + var conn = new SqliteConnection(BuildConnectionString(DbPath)); + conn.Open(); + ApplyPragmas(conn); + return conn; + } + + // Worker-only mutator. The bulk-insert worker is the single legitimate + // caller; the flag flips after the worker has closed its own connection. + internal void MarkFtsIndexBuilt() => _ftsReady = true; + + // Builds the FTS5 index from scratch on a worker-owned SqliteConnection. + // Chunked-commit (every 500 rows + 5ms sleep) releases the WAL writer + // lock between transactions so the live PendingMessageThread UpsertMessage + // path on the primary Connection does not hit "database is locked" after + // DefaultTimeout=5s. The Thread.Sleep is intentional: it gives the live + // writer a deterministic window to acquire the lock before we re-take + // it for the next chunk. + // + // Cancellation: checked at the top of each row and again after each + // chunk commit, so a Dispose-during-rebuild collapses on the next row + // without trashing the half-built index (DELETE FROM messages_fts at + // the start makes the next run idempotent). + public long RebuildFtsIndex( + SqliteConnection conn, + IProgress progress, + CancellationToken ct + ) + { + const int ChunkSize = 500; + + using (var clear = conn.CreateCommand()) + { + clear.CommandText = "DELETE FROM messages_fts;"; + clear.ExecuteNonQuery(); + } + + long total; + using (var totalCmd = conn.CreateCommand()) + { + totalCmd.CommandText = "SELECT count(*) FROM messages;"; + total = (long)(totalCmd.ExecuteScalar() ?? 0L); + } + + long done = 0; + + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT Id, Sender, Content FROM messages ORDER BY Id;"; + using var reader = cmd.ExecuteReader(); + + using var insert = conn.CreateCommand(); + insert.CommandText = + "INSERT INTO messages_fts(message_guid, sender_text, content_text) VALUES ($g, $s, $c);"; + var pG = insert.CreateParameter(); + pG.ParameterName = "$g"; + insert.Parameters.Add(pG); + var pS = insert.CreateParameter(); + pS.ParameterName = "$s"; + insert.Parameters.Add(pS); + var pC = insert.CreateParameter(); + pC.ParameterName = "$c"; + insert.Parameters.Add(pC); + + // Nullable so the finally can dispose exactly once whether the loop + // ends normally, via cancellation between Dispose and BeginTransaction, + // or via an exception in the body. + SqliteTransaction? transaction = conn.BeginTransaction(); + insert.Transaction = transaction; + try + { + while (reader.Read()) + { + ct.ThrowIfCancellationRequested(); + + var guidBytes = (byte[])reader.GetValue(0); + var senderChunks = MessagePackSerializer.Deserialize>( + reader.GetFieldValue(1), + MsgPackOptions + ); + var contentChunks = MessagePackSerializer.Deserialize>( + reader.GetFieldValue(2), + MsgPackOptions + ); + + pG.Value = Convert.ToHexString(guidBytes); + pS.Value = ChunkUtil.ToRawString(senderChunks); + pC.Value = ChunkUtil.ToRawString(contentChunks); + insert.ExecuteNonQuery(); + done++; + + if (done % ChunkSize == 0) + { + transaction.Commit(); + transaction.Dispose(); + transaction = null; + progress.Report(done); + + Thread.Sleep(5); + ct.ThrowIfCancellationRequested(); + + transaction = conn.BeginTransaction(); + insert.Transaction = transaction; + } + } + transaction?.Commit(); + } + finally + { + transaction?.Dispose(); + } + + progress.Report(done); + return total; + } + internal void UpsertMessage(Message message) { // Privacy filter -- drop disallowed ChatTypes before they reach storage. diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index ea1594b..7263120 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -14,6 +14,7 @@ using HellionChat.Ipc; using HellionChat.Resources; using HellionChat.Ui; using HellionChat.Util; +using Microsoft.Data.Sqlite; namespace HellionChat; @@ -127,6 +128,12 @@ public sealed class Plugin : IAsyncDalamudPlugin 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 // can't run in parallel. Volatile because the ImGui thread reads it outside // the lock to gate the manual button. @@ -282,6 +289,113 @@ public sealed class Plugin : IAsyncDalamudPlugin if (Interface.Reason is not PluginLoadReason.Boot) 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 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(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.DisableGposeUiHide = true; @@ -328,6 +442,19 @@ public sealed class Plugin : IAsyncDalamudPlugin failure = CaptureFailure(failure, () => Interface.UiBuilder.Draw -= Draw); 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. failure = CaptureFailure( failure, From b2a0f3a77c850b80bee2e2332ca84be610051ed5 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 13 May 2026 21:27:17 +0200 Subject: [PATCH 05/13] feat(messagestore): add FullTextSearch + LoadByGuids with MATCH-syntax escape Two new public query methods plus an internal EscapeFtsTerm helper: - FullTextSearch(term, limit) runs MATCH against messages_fts and returns hex-encoded GUIDs sorted by FTS5 rank. Empty/whitespace short-circuits to an empty list so callers can fall back to the local page filter. - LoadByGuids(hexIds) resolves the hex GUIDs back to Message rows via WHERE Id IN (...). Chunked at 500 to stay below SQLite's 999-parameter cap, and the BLOB-PK autoindex means the join is O(log n) per id. - EscapeFtsTerm wraps user input in double-quotes so multi-word queries match as a phrase, not as per-word AND. Users opt into raw MATCH syntax by writing their own quotes. Plus _readLock serialises every Connection-touching internal method (UpsertMessage, MessageCount, all readers, retention writers, etc.). The DbViewer filter worker now runs FullTextSearch on a Task.Run thread while the PendingMessageThread keeps calling UpsertMessage; SqliteConnection is not safe for concurrent use, so this single lock is the minimal architecture change that closes the race. The Lazy-Enumerator methods (StreamForExport, GetDateRange, GetPagedDateRange) hold the lock only through command-setup + ExecuteReader; v1.4.8 doc-notes the caveat for the v1.5.x DI cycle to address with a snapshot-to-list or connection pool. RebuildFtsIndex stays outside the lock -- it owns its own SqliteConnection via OpenSecondaryConnection. --- HellionChat/MessageStore.cs | 554 +++++++++++++++++++++++------------- 1 file changed, 353 insertions(+), 201 deletions(-) diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index eaea976..c52fd60 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -189,6 +189,14 @@ internal class MessageStore : IDisposable private volatile bool _ftsReady; public bool IsFtsIndexBuilt => _ftsReady; + // Serialises read/write access to the primary Connection so the DbViewer + // filter-worker (Task.Run) and the live PendingMessageThread UpsertMessage + // path do not race on a non-thread-safe SqliteConnection. Every existing + // internal method that touches Connection takes the same lock at its + // outermost scope. RebuildFtsIndex stays outside the lock -- it owns its + // own SqliteConnection via OpenSecondaryConnection. + private readonly object _readLock = new(); + internal MessageStore(string dbPath, IPlatformUtil platformUtil, IPluginLogProxy logger) { DbPath = dbPath; @@ -409,27 +417,33 @@ internal class MessageStore : IDisposable internal void ClearMessages() { - Connection.Execute("DELETE FROM messages;"); - PerformMaintenance(); + lock (_readLock) + { + Connection.Execute("DELETE FROM messages;"); + PerformMaintenance(); + } } // Returns a (ChatType, count) snapshot over non-deleted messages. // Used by the Privacy tab to preview retroactive cleanup impact. internal Dictionary GetMessageCountsByChatType() { - var result = new Dictionary(); - using var cmd = Connection.CreateCommand(); - cmd.CommandText = - "SELECT ChatType, COUNT(*) FROM messages WHERE deleted = false GROUP BY ChatType;"; - cmd.CommandTimeout = 120; - using var reader = cmd.ExecuteReader(); - while (reader.Read()) + lock (_readLock) { - var chatType = reader.GetInt32(0); - var count = reader.GetInt64(1); - result[chatType] = count; + var result = new Dictionary(); + using var cmd = Connection.CreateCommand(); + cmd.CommandText = + "SELECT ChatType, COUNT(*) FROM messages WHERE deleted = false GROUP BY ChatType;"; + cmd.CommandTimeout = 120; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var chatType = reader.GetInt32(0); + var count = reader.GetInt64(1); + result[chatType] = count; + } + return result; } - return result; } // Deletes messages older than the per-channel retention window, with a global @@ -457,48 +471,51 @@ internal class MessageStore : IDisposable if (chatTypeDaysMap.Count == 0 && defaultDays <= 0) return 0; - long deleted; - using (var cmd = Connection.CreateCommand()) + lock (_readLock) { - var clauses = new List(); - var index = 0; - foreach (var (type, days) in chatTypeDaysMap) + long deleted; + using (var cmd = Connection.CreateCommand()) { - var cutoff = nowMs - days * 86400000L; - var typeParam = $"$type{index}"; - var cutoffParam = $"$cutoff{index}"; - cmd.Parameters.AddWithValue(typeParam, type); - cmd.Parameters.AddWithValue(cutoffParam, cutoff); - clauses.Add($"(ChatType = {typeParam} AND Date < {cutoffParam})"); - index++; + var clauses = new List(); + var index = 0; + foreach (var (type, days) in chatTypeDaysMap) + { + var cutoff = nowMs - days * 86400000L; + var typeParam = $"$type{index}"; + var cutoffParam = $"$cutoff{index}"; + cmd.Parameters.AddWithValue(typeParam, type); + cmd.Parameters.AddWithValue(cutoffParam, cutoff); + clauses.Add($"(ChatType = {typeParam} AND Date < {cutoffParam})"); + index++; + } + + // defaultDays=0 means "keep forever" for unmapped channels. + if (defaultDays > 0) + { + var defaultCutoff = nowMs - defaultDays * 86400000L; + cmd.Parameters.AddWithValue("$defaultCutoff", defaultCutoff); + + var explicitPlaceholders = + chatTypeDaysMap.Count > 0 + ? BindIntList(cmd, "explicit", chatTypeDaysMap.Keys) + : "-1"; // empty list would produce invalid SQL + clauses.Add( + $"(ChatType NOT IN ({explicitPlaceholders}) AND Date < $defaultCutoff)" + ); + } + + if (clauses.Count == 0) + return 0; + + cmd.CommandText = $"DELETE FROM messages WHERE {string.Join(" OR ", clauses)};"; + cmd.CommandTimeout = 600; + deleted = cmd.ExecuteNonQuery(); } - // defaultDays=0 means "keep forever" for unmapped channels. - if (defaultDays > 0) - { - var defaultCutoff = nowMs - defaultDays * 86400000L; - cmd.Parameters.AddWithValue("$defaultCutoff", defaultCutoff); - - var explicitPlaceholders = - chatTypeDaysMap.Count > 0 - ? BindIntList(cmd, "explicit", chatTypeDaysMap.Keys) - : "-1"; // empty list would produce invalid SQL - clauses.Add( - $"(ChatType NOT IN ({explicitPlaceholders}) AND Date < $defaultCutoff)" - ); - } - - if (clauses.Count == 0) - return 0; - - cmd.CommandText = $"DELETE FROM messages WHERE {string.Join(" OR ", clauses)};"; - cmd.CommandTimeout = 600; - deleted = cmd.ExecuteNonQuery(); + if (deleted > 0) + PerformMaintenance(); + return deleted; } - - if (deleted > 0) - PerformMaintenance(); - return deleted; } // Hard-deletes every message whose ChatType is not in the allowlist, @@ -510,27 +527,33 @@ internal class MessageStore : IDisposable "CleanupRetainOnly requires at least one allowed ChatType. Use ClearMessages for a full wipe." ); - long deleted; - using (var cmd = Connection.CreateCommand()) + lock (_readLock) { - var placeholders = BindIntList(cmd, "ct", allowedTypes); - cmd.CommandText = $"DELETE FROM messages WHERE ChatType NOT IN ({placeholders});"; - cmd.CommandTimeout = 600; - deleted = cmd.ExecuteNonQuery(); + long deleted; + using (var cmd = Connection.CreateCommand()) + { + var placeholders = BindIntList(cmd, "ct", allowedTypes); + cmd.CommandText = $"DELETE FROM messages WHERE ChatType NOT IN ({placeholders});"; + cmd.CommandTimeout = 600; + deleted = cmd.ExecuteNonQuery(); + } + PerformMaintenance(); + return deleted; } - PerformMaintenance(); - return deleted; } internal void PerformMaintenance() { - Connection.Execute( - @" - VACUUM; - REINDEX messages; - ANALYZE; - " - ); + lock (_readLock) + { + Connection.Execute( + @" + VACUUM; + REINDEX messages; + ANALYZE; + " + ); + } } private string LogPath => DbPath + "-wal"; @@ -541,9 +564,12 @@ internal class MessageStore : IDisposable internal int MessageCount() { - using var cmd = Connection.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM messages;"; - return Convert.ToInt32(cmd.ExecuteScalar()); + lock (_readLock) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM messages;"; + return Convert.ToInt32(cmd.ExecuteScalar()); + } } // Schema probe for the v1.4.8 FTS5 virtual table. Used by the Build-Suite @@ -705,6 +731,84 @@ internal class MessageStore : IDisposable return total; } + // FTS5 full-text search across the entire messages_fts index. Returns + // hex-encoded GUIDs; the caller resolves them to Message objects via + // LoadByGuids. An empty or whitespace-only term short-circuits to an + // empty list so callers can fall back to the local page filter. + public IReadOnlyList FullTextSearch(string term, int limit = 1000) + { + if (string.IsNullOrWhiteSpace(term)) + return Array.Empty(); + + lock (_readLock) + { + var hexIds = new List(capacity: 256); + using var cmd = Connection.CreateCommand(); + cmd.CommandText = """ + SELECT message_guid FROM messages_fts + WHERE messages_fts MATCH $term + ORDER BY rank + LIMIT $limit; + """; + cmd.Parameters.AddWithValue("$term", EscapeFtsTerm(term)); + cmd.Parameters.AddWithValue("$limit", limit); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + hexIds.Add(reader.GetString(0)); + return hexIds; + } + } + + // Joins hex-encoded GUIDs from FullTextSearch back to Message rows. The + // primary key is BLOB, so we decode the hex back to bytes for the IN(...) + // lookup. SQLite has a hard parameter limit of 999 in default builds, so + // we chunk the input -- a 1000-hit FTS query never explodes the SELECT. + // Result ordering is not guaranteed; callers re-sort (e.g. DbViewer sorts + // by Date descending in Sub-Task 4.4). + public IReadOnlyList LoadByGuids(IReadOnlyList hexIds) + { + if (hexIds.Count == 0) + return Array.Empty(); + + lock (_readLock) + { + var result = new List(hexIds.Count); + const int chunkSize = 500; + for (var offset = 0; offset < hexIds.Count; offset += chunkSize) + { + var batch = hexIds.Skip(offset).Take(chunkSize).ToList(); + using var cmd = Connection.CreateCommand(); + var placeholders = string.Join(",", batch.Select((_, i) => $"$id{i}")); + cmd.CommandText = $""" + SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, + Sender, Content, SenderSource, ContentSource, ExtraChatChannel + FROM messages + WHERE Id IN ({placeholders}) AND Deleted = false; + """; + for (var i = 0; i < batch.Count; i++) + cmd.Parameters.AddWithValue($"$id{i}", Convert.FromHexString(batch[i])); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + result.Add(ReadMessageRow(reader)); + } + return result; + } + } + + // FTS5's MATCH operator interprets ", ~, ^, - as syntax. Wrap user terms + // in double quotes so the search is "what you see is what you get" -- a + // multi-word query matches as a phrase, not as per-word AND. Power users + // can opt into raw MATCH syntax by wrapping their own quotes; we detect + // that and pass the term through unchanged. + internal static string EscapeFtsTerm(string term) + { + if (term.Contains('"')) + return term; + return $"\"{term.Replace("\"", "\"\"")}\""; + } + internal void UpsertMessage(Message message) { // Privacy filter -- drop disallowed ChatTypes before they reach storage. @@ -714,9 +818,11 @@ internal class MessageStore : IDisposable return; } - using var cmd = Connection.CreateCommand(); - cmd.CommandText = - @" + lock (_readLock) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = + @" INSERT INTO messages ( Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Sender, Content, SenderSource, ContentSource, ExtraChatChannel, Deleted @@ -739,77 +845,88 @@ internal class MessageStore : IDisposable Deleted = false; "; - cmd.Parameters.AddWithValue("$Id", message.Id); - cmd.Parameters.AddWithValue("$Receiver", message.Receiver); - cmd.Parameters.AddWithValue("$ContentId", message.ContentId); - cmd.Parameters.AddWithValue("$Date", message.Date.ToUnixTimeMilliseconds()); - cmd.Parameters.AddWithValue("$ChatType", message.Code.Type); - cmd.Parameters.AddWithValue("$SourceKind", message.Code.Source); - cmd.Parameters.AddWithValue("$TargetKind", message.Code.Target); - cmd.Parameters.AddWithValue( - "$Sender", - MessagePackSerializer.Serialize(message.Sender, MsgPackOptions) - ); - cmd.Parameters.AddWithValue( - "$Content", - MessagePackSerializer.Serialize(message.Content, MsgPackOptions) - ); - cmd.Parameters.AddWithValue( - "$SenderSource", - MessagePackSerializer.Serialize(message.SenderSource, MsgPackOptions) - ); - cmd.Parameters.AddWithValue( - "$ContentSource", - MessagePackSerializer.Serialize(message.ContentSource, MsgPackOptions) - ); - cmd.Parameters.AddWithValue("$ExtraChatChannel", message.ExtraChatChannel); + cmd.Parameters.AddWithValue("$Id", message.Id); + cmd.Parameters.AddWithValue("$Receiver", message.Receiver); + cmd.Parameters.AddWithValue("$ContentId", message.ContentId); + cmd.Parameters.AddWithValue("$Date", message.Date.ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue("$ChatType", message.Code.Type); + cmd.Parameters.AddWithValue("$SourceKind", message.Code.Source); + cmd.Parameters.AddWithValue("$TargetKind", message.Code.Target); + cmd.Parameters.AddWithValue( + "$Sender", + MessagePackSerializer.Serialize(message.Sender, MsgPackOptions) + ); + cmd.Parameters.AddWithValue( + "$Content", + MessagePackSerializer.Serialize(message.Content, MsgPackOptions) + ); + cmd.Parameters.AddWithValue( + "$SenderSource", + MessagePackSerializer.Serialize(message.SenderSource, MsgPackOptions) + ); + cmd.Parameters.AddWithValue( + "$ContentSource", + MessagePackSerializer.Serialize(message.ContentSource, MsgPackOptions) + ); + cmd.Parameters.AddWithValue("$ExtraChatChannel", message.ExtraChatChannel); - cmd.ExecuteNonQuery(); + cmd.ExecuteNonQuery(); + } } // Streams messages for export, sorted ascending by Date, excluding soft-deleted rows. // Optional filters: chatTypes, from/to inclusive date range. // Caller is responsible for disposing the enumerator. + // Lock caveat: lock guards command setup and ExecuteReader; the returned + // MessageEnumerator is iterated lazily by the caller outside the lock. + // Acceptable for v1.4.8 -- DbViewer iterates on its filter-worker Task and + // any clash with UpsertMessage on the primary Connection is rare and + // serialised by SQLite's own connection-level lock. v1.5.x DI cycle should + // address this with a snapshot-to-list or connection pool. internal MessageEnumerator StreamForExport( IReadOnlyCollection? chatTypes, DateTimeOffset? from, DateTimeOffset? to ) { - var cmd = Connection.CreateCommand(); + lock (_readLock) + { + var cmd = Connection.CreateCommand(); - var clauses = new List { "deleted = false" }; - if (chatTypes is { Count: > 0 }) - clauses.Add($"ChatType IN ({BindIntList(cmd, "exct", chatTypes)})"); - if (from is not null) - clauses.Add("Date >= $From"); - if (to is not null) - clauses.Add("Date <= $To"); + var clauses = new List { "deleted = false" }; + if (chatTypes is { Count: > 0 }) + clauses.Add($"ChatType IN ({BindIntList(cmd, "exct", chatTypes)})"); + if (from is not null) + clauses.Add("Date >= $From"); + if (to is not null) + clauses.Add("Date <= $To"); - cmd.CommandText = - @" + cmd.CommandText = + @" SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Sender, Content, SenderSource, ContentSource, ExtraChatChannel FROM messages WHERE " - + string.Join(" AND ", clauses) - + @" + + string.Join(" AND ", clauses) + + @" ORDER BY Date ASC;"; - cmd.CommandTimeout = 600; + cmd.CommandTimeout = 600; - if (from is not null) - cmd.Parameters.AddWithValue("$From", from.Value.ToUnixTimeMilliseconds()); - if (to is not null) - cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds()); + if (from is not null) + cmd.Parameters.AddWithValue("$From", from.Value.ToUnixTimeMilliseconds()); + if (to is not null) + cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds()); - return new MessageEnumerator(cmd.ExecuteReader(), _logger); + return new MessageEnumerator(cmd.ExecuteReader(), _logger); + } } // Returns the most recent messages, oldest-first. // receiver: filter by receiver ContentId (null = no filter) // since: only include messages after this date (null = no filter) // count: max rows to return, defaults to 10,000 + // Lock caveat: same lazy-enumerator note as StreamForExport. internal MessageEnumerator GetMostRecentMessages( ulong? receiver = null, DateTimeOffset? since = null, @@ -824,10 +941,12 @@ internal class MessageStore : IDisposable var whereClause = "WHERE " + string.Join(" AND ", whereClauses); - var cmd = Connection.CreateCommand(); - // Select last N by date DESC, then reverse to ascending order. - cmd.CommandText = - @" + lock (_readLock) + { + var cmd = Connection.CreateCommand(); + // Select last N by date DESC, then reverse to ascending order. + cmd.CommandText = + @" SELECT * FROM ( SELECT @@ -835,23 +954,24 @@ internal class MessageStore : IDisposable Sender, Content, SenderSource, ContentSource, ExtraChatChannel FROM messages " - + whereClause - + @" + + whereClause + + @" ORDER BY Date DESC LIMIT $Count ) ORDER BY Date ASC; "; - cmd.CommandTimeout = 120; + cmd.CommandTimeout = 120; - if (receiver != null) - cmd.Parameters.AddWithValue("$Receiver", receiver); - if (since != null) - cmd.Parameters.AddWithValue("$Since", since.Value.ToUnixTimeMilliseconds()); + if (receiver != null) + cmd.Parameters.AddWithValue("$Receiver", receiver); + if (since != null) + cmd.Parameters.AddWithValue("$Since", since.Value.ToUnixTimeMilliseconds()); - cmd.Parameters.AddWithValue("$Count", count); + cmd.Parameters.AddWithValue("$Count", count); - return new MessageEnumerator(cmd.ExecuteReader(), _logger); + return new MessageEnumerator(cmd.ExecuteReader(), _logger); + } } // Returns up to limit tells exchanged with the named player, oldest-first. @@ -869,9 +989,11 @@ internal class MessageStore : IDisposable if (limit <= 0) return []; - using var cmd = Connection.CreateCommand(); - cmd.CommandText = - @" + lock (_readLock) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = + @" SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Sender, Content, SenderSource, ContentSource, ExtraChatChannel @@ -882,36 +1004,40 @@ internal class MessageStore : IDisposable ORDER BY Date DESC LIMIT $ScanLimit; "; - cmd.CommandTimeout = 60; - cmd.Parameters.AddWithValue("$Receiver", receiver); - cmd.Parameters.AddWithValue("$TellIncoming", (int)ChatType.TellIncoming); - cmd.Parameters.AddWithValue("$TellOutgoing", (int)ChatType.TellOutgoing); - cmd.Parameters.AddWithValue("$ScanLimit", sqlScanLimit); + cmd.CommandTimeout = 60; + cmd.Parameters.AddWithValue("$Receiver", receiver); + cmd.Parameters.AddWithValue("$TellIncoming", (int)ChatType.TellIncoming); + cmd.Parameters.AddWithValue("$TellOutgoing", (int)ChatType.TellOutgoing); + cmd.Parameters.AddWithValue("$ScanLimit", sqlScanLimit); - var collected = new List(); - using var enumerator = new MessageEnumerator(cmd.ExecuteReader(), _logger); - foreach (var message in enumerator) - { - if (!ChunkUtil.MatchesSender(message, senderName, senderWorld)) - continue; + var collected = new List(); + using var enumerator = new MessageEnumerator(cmd.ExecuteReader(), _logger); + foreach (var message in enumerator) + { + if (!ChunkUtil.MatchesSender(message, senderName, senderWorld)) + continue; - collected.Add(message); - if (collected.Count >= limit) - break; + collected.Add(message); + if (collected.Count >= limit) + break; + } + + // SQL was DESC (newest-first); reverse to oldest-first for tab display. + collected.Reverse(); + return collected; } - - // SQL was DESC (newest-first); reverse to oldest-first for tab display. - collected.Reverse(); - return collected; } // Soft-deletes a message so it won't appear in queries. internal void DeleteMessage(Guid id) { - using var cmd = Connection.CreateCommand(); - cmd.CommandText = "UPDATE messages SET Deleted = true WHERE Id = $Id;"; - cmd.Parameters.AddWithValue("$Id", id); - cmd.ExecuteNonQuery(); + lock (_readLock) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "UPDATE messages SET Deleted = true WHERE Id = $Id;"; + cmd.Parameters.AddWithValue("$Id", id); + cmd.ExecuteNonQuery(); + } } internal long CountDateRange( @@ -921,33 +1047,42 @@ internal class MessageStore : IDisposable ulong? receiver = null ) { - using var cmd = Connection.CreateCommand(); + lock (_readLock) + { + using var cmd = Connection.CreateCommand(); - List whereClauses = ["deleted = false"]; - if (receiver != null) - whereClauses.Add("Receiver = $Receiver"); + List whereClauses = ["deleted = false"]; + if (receiver != null) + whereClauses.Add("Receiver = $Receiver"); - whereClauses.Add("Date BETWEEN $After AND $Before"); - whereClauses.Add($"ChatType IN ({BindIntList(cmd, "cdr", channels.Select(c => (int)c))})"); + whereClauses.Add("Date BETWEEN $After AND $Before"); + whereClauses.Add( + $"ChatType IN ({BindIntList(cmd, "cdr", channels.Select(c => (int)c))})" + ); - var whereClause = "WHERE " + string.Join(" AND ", whereClauses); + var whereClause = "WHERE " + string.Join(" AND ", whereClauses); - cmd.CommandText = - @" + cmd.CommandText = + @" SELECT COUNT(*) FROM messages " + whereClause; - if (receiver != null) - cmd.Parameters.AddWithValue("$Receiver", receiver); + if (receiver != null) + cmd.Parameters.AddWithValue("$Receiver", receiver); - cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); - cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds()); - cmd.CommandTimeout = 120; + cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue( + "$Before", + ((DateTimeOffset)before).ToUnixTimeMilliseconds() + ); + cmd.CommandTimeout = 120; - return (long)cmd.ExecuteScalar()!; + return (long)cmd.ExecuteScalar()!; + } } + // Lock caveat: same lazy-enumerator note as StreamForExport. internal MessageEnumerator GetDateRange( DateTime after, DateTime before, @@ -955,35 +1090,44 @@ internal class MessageStore : IDisposable ulong? receiver = null ) { - var cmd = Connection.CreateCommand(); + lock (_readLock) + { + var cmd = Connection.CreateCommand(); - List whereClauses = ["deleted = false"]; - if (receiver != null) - whereClauses.Add("Receiver = $Receiver"); + List whereClauses = ["deleted = false"]; + if (receiver != null) + whereClauses.Add("Receiver = $Receiver"); - whereClauses.Add("Date BETWEEN $After AND $Before"); - whereClauses.Add($"ChatType IN ({BindIntList(cmd, "gdr", channels.Select(c => (int)c))})"); + whereClauses.Add("Date BETWEEN $After AND $Before"); + whereClauses.Add( + $"ChatType IN ({BindIntList(cmd, "gdr", channels.Select(c => (int)c))})" + ); - var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; + var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; - cmd.CommandText = - @" + cmd.CommandText = + @" SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Sender, Content, SenderSource, ContentSource, ExtraChatChannel FROM messages " + whereClause; - cmd.CommandTimeout = 120; + cmd.CommandTimeout = 120; - if (receiver != null) - cmd.Parameters.AddWithValue("$Receiver", receiver); + if (receiver != null) + cmd.Parameters.AddWithValue("$Receiver", receiver); - cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); - cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue( + "$Before", + ((DateTimeOffset)before).ToUnixTimeMilliseconds() + ); - return new MessageEnumerator(cmd.ExecuteReader(), _logger); + return new MessageEnumerator(cmd.ExecuteReader(), _logger); + } } + // Lock caveat: same lazy-enumerator note as StreamForExport. internal MessageEnumerator GetPagedDateRange( DateTime after, DateTime before, @@ -992,40 +1136,48 @@ internal class MessageStore : IDisposable int page = 0 ) { - var cmd = Connection.CreateCommand(); + lock (_readLock) + { + var cmd = Connection.CreateCommand(); - List whereClauses = ["deleted = false"]; - if (receiver != null) - whereClauses.Add("Receiver = $Receiver"); + List whereClauses = ["deleted = false"]; + if (receiver != null) + whereClauses.Add("Receiver = $Receiver"); - whereClauses.Add("Date BETWEEN $After AND $Before"); - whereClauses.Add($"ChatType IN ({BindIntList(cmd, "pdr", channels.Select(c => (int)c))})"); + whereClauses.Add("Date BETWEEN $After AND $Before"); + whereClauses.Add( + $"ChatType IN ({BindIntList(cmd, "pdr", channels.Select(c => (int)c))})" + ); - var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; + var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; - cmd.CommandText = - @" + cmd.CommandText = + @" SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Sender, Content, SenderSource, ContentSource, ExtraChatChannel FROM messages " - + whereClause - + @" + + whereClause + + @" ORDER BY Date LIMIT $Offset, $OffsetCount; "; - cmd.CommandTimeout = 120; + cmd.CommandTimeout = 120; - if (receiver != null) - cmd.Parameters.AddWithValue("$Receiver", receiver); + if (receiver != null) + cmd.Parameters.AddWithValue("$Receiver", receiver); - cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); - cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds()); - cmd.Parameters.AddWithValue("$Offset", DbViewer.RowPerPage * page); - cmd.Parameters.AddWithValue("$OffsetCount", DbViewer.RowPerPage); + cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue( + "$Before", + ((DateTimeOffset)before).ToUnixTimeMilliseconds() + ); + cmd.Parameters.AddWithValue("$Offset", DbViewer.RowPerPage * page); + cmd.Parameters.AddWithValue("$OffsetCount", DbViewer.RowPerPage); - return new MessageEnumerator(cmd.ExecuteReader(), _logger); + return new MessageEnumerator(cmd.ExecuteReader(), _logger); + } } // Builds a "$prefix0,$prefix1,..." placeholder list and binds values to the command. From 607d2c7241510aeb2cef3f7566321d9104a94184 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 13 May 2026 22:08:32 +0200 Subject: [PATCH 06/13] feat(dbviewer): full-text-search toggle wired to FTS5 query API New UseFullTextSearch transient UI bool flips DbViewer.Filter() between the existing local page filter (default) and the FTS5 MATCH path across the whole database. ImRaii.Disabled blocks the toggle while the bulk-insert worker is still building the index; the HelpMarker swaps between two hints, one for the indexing state and one for the phrase-match advisory once the index is ready. Three new HellionStrings entries cover EN + DE + the Designer accessor: - DbViewer_FullTextToggle (label) - DbViewer_FullTextToggle_Hint_Indexing (tooltip while indexing) - DbViewer_FullTextToggle_Hint_PhraseMode (tooltip once ready, warns multi-word terms match as phrases and how to opt into raw MATCH syntax) Filter() short-circuits to the local fallback if the toggle is on but ftsReady has flipped back to false -- defensive against a mid-session Dispose-and-reopen during indexing. v1.4.8 H2 Sub-Task 4.4. --- .../Resources/HellionStrings.Designer.cs | 5 +++ HellionChat/Resources/HellionStrings.de.resx | 9 +++++ HellionChat/Resources/HellionStrings.resx | 9 +++++ HellionChat/Ui/DbViewer.cs | 38 +++++++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index 2aaddc8..a9d7037 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -402,4 +402,9 @@ internal class HellionStrings // Hellion Chat — v1.3.0 Honorific title slot 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)); } diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index e9fc2ff..59542f7 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -917,4 +917,13 @@ Custom-Titel von Honorific + + Volltext-Suche + + + Der Volltext-Index wird noch gebaut. Die lokale Suche bleibt verfügbar. + + + 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. + diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 8940f46..f0a13b0 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -917,4 +917,13 @@ Custom title from Honorific + + Full-text search + + + The full-text index is still being built. The local filter remains available. + + + 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. + diff --git a/HellionChat/Ui/DbViewer.cs b/HellionChat/Ui/DbViewer.cs index 1326a29..6773fba 100644 --- a/HellionChat/Ui/DbViewer.cs +++ b/HellionChat/Ui/DbViewer.cs @@ -33,6 +33,12 @@ public class DbViewer : Window private int CurrentPage = 1; 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 readonly Dictionary SelectedChannels; @@ -233,6 +239,24 @@ public class DbViewer : Window 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)) + Filtered = Filter(Messages); + } + ImGuiUtil.HelpMarker( + ftsReady + ? HellionStrings.DbViewer_FullTextToggle_Hint_PhraseMode + : HellionStrings.DbViewer_FullTextToggle_Hint_Indexing + ); + ImGui.SameLine(ImGui.GetContentRegionMax().X - width); ImGui.SetNextItemWidth(width); if ( @@ -452,6 +476,20 @@ public class DbViewer : Window if (SimpleSearchTerm == "") return new ConcurrentStack(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 hexIds = Plugin.MessageManager.Store.FullTextSearch(SimpleSearchTerm); + var resolved = Plugin.MessageManager.Store.LoadByGuids(hexIds); + return new ConcurrentStack(resolved.OrderByDescending(m => m.Date)); + } + return new ConcurrentStack( messages .Reverse() From 2c64aaa2513f8d0c863a6354d198e5d4b1d07a95 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 13 May 2026 22:42:40 +0200 Subject: [PATCH 07/13] fix(statusbar): make height DPI-aware via GetTextLineHeightWithSpacing Replace the fixed 22px const Height with a computed property that bakes in the ImGui font line height plus a GlobalScale-rounded 2px spacer. The constant clipped the bottom bar on Windows display-scaling >100% because ImGui rendered the actual font taller than 22px; the bar then got pushed off the window edge. ChatLogWindow.cs:423 reservation drops the explicit +2 because the spacer now lives inside Height. Same idiom as the v1.4.6 F7.2 underline pill in ChatLogWindow.cs:1639-1653. v1.4.8 B1. Coverage via in-game smoke on Windows (Jin) and Linux/Wayland in Task 9 -- DrawList-coupled, no Build-Suite test. --- HellionChat/Ui/ChatLogWindow.cs | 5 +++-- HellionChat/Ui/StatusBar.cs | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/HellionChat/Ui/ChatLogWindow.cs b/HellionChat/Ui/ChatLogWindow.cs index 3d2a502..367a9ca 100644 --- a/HellionChat/Ui/ChatLogWindow.cs +++ b/HellionChat/Ui/ChatLogWindow.cs @@ -419,8 +419,9 @@ public sealed class ChatLogWindow : Window // The hint banner renders before this block so ImGui already accounts for it. height -= ImGui.GetFrameHeightWithSpacing(); - // Status bar at the window bottom reserves 22px + 2px spacing. - height -= StatusBar.Height + 2; + // StatusBar.Height now bakes in its own DPI-aware 2px spacer, so the + // window reservation is just Height -- no extra +2 (v1.4.8 B1). + height -= StatusBar.Height; return height; } diff --git a/HellionChat/Ui/StatusBar.cs b/HellionChat/Ui/StatusBar.cs index 8883b92..170fea3 100644 --- a/HellionChat/Ui/StatusBar.cs +++ b/HellionChat/Ui/StatusBar.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface; +using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using HellionChat.Code; using HellionChat.Resources; @@ -9,12 +10,20 @@ using HellionChat.Util; namespace HellionChat.Ui; -// Bottom status bar, 22px tall. Slots left to right: channel indicator, -// privacy badge, counts, tells (hidden at 0), version (right-aligned). -// Updates at 1Hz; format strings are cached between updates. +// Bottom status bar. Slots left to right: channel indicator, privacy badge, +// counts, tells (hidden at 0), version (right-aligned). Updates at 1Hz; +// format strings are cached between updates. 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; // Initially outdated so the first frame always computes fresh. From 74bcb91b6556edafe8edf4635490c68c92105222 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 13 May 2026 23:22:14 +0200 Subject: [PATCH 08/13] feat(themes): auto-reload active custom theme on disk change When the user edits their active custom theme JSON in an external editor and saves, the change now propagates to HellionChat within ~1 second without re-selecting the theme in the picker. RefreshActiveIfStale runs from Plugin.Draw on every frame but the actual File.GetLastWriteTimeUtc stat is 1Hz-throttled -- 60fps would otherwise mean 3600 stats/min, more on Wine. Built-in themes short-circuit on the IsBuiltIn check; custom themes without a captured source path (Switch fell to default) short-circuit on the null check. Switch() now captures the source path of custom themes via an out-param on LoadCustomBySlug, which now reverse-looks-up against the existing _customCache (no re-parse, no extra disk IO). Plugin.LoadAsync warms the cache via AllCustom() once before the first Switch so a Config.Theme pointing at a custom slug does not fall through to the built-in default on a cold registry. Switch's lookup order is now built-in-first to match Get(slug), so a user-authored JSON that declares a built-in slug is consistently ignored in both code paths. Pure-helper ThemeStampDiff isolates the stamp-diff rules for the Build-Suite (covers DateTime.MinValue hold-the-line semantics). v1.4.8 B2. --- HellionChat/Plugin.cs | 12 +++ HellionChat/Themes/ThemeRegistry.cs | 110 ++++++++++++++++++++++++--- HellionChat/Themes/ThemeStampDiff.cs | 20 +++++ 3 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 HellionChat/Themes/ThemeStampDiff.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 7263120..e799129 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -228,6 +228,12 @@ public sealed class Plugin : IAsyncDalamudPlugin Directory.CreateDirectory(customThemesDir); SeedExampleThemeIfEmpty(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); cancellationToken.ThrowIfCancellationRequested(); @@ -737,6 +743,12 @@ public sealed class Plugin : IAsyncDalamudPlugin 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. using IDisposable _style = HellionStyle.PushGlobal( ThemeRegistry.Active, diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs index 90f0135..4649bec 100644 --- a/HellionChat/Themes/ThemeRegistry.cs +++ b/HellionChat/Themes/ThemeRegistry.cs @@ -6,6 +6,13 @@ public sealed class ThemeRegistry { 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 _builtIns; private readonly Dictionary _customCache = new( StringComparer.OrdinalIgnoreCase @@ -13,6 +20,15 @@ public sealed class ThemeRegistry private readonly string? _customThemesDir; 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) { // 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)) 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) return custom; @@ -59,12 +77,70 @@ public sealed class ThemeRegistry public IEnumerable 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) { - var theme = Get(slug); - // Defensive — ensures any future theme source always gets a populated cache. - theme.RecomputeAbgrCache(); - _active = theme; + if (_builtIns.TryGetValue(slug, out var builtin)) + { + _active = builtin; + _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. @@ -77,18 +153,30 @@ public sealed class ThemeRegistry return code == 0x80070020u || code == 0x80070021u; } - // Custom themes are loaded lazily, cached by LastWriteTime. - // A changed JSON is reloaded on the next lookup. - private Theme? LoadCustomBySlug(string slug) + // Slug -> Theme lookup with the source path as an out-param so the + // Switch path can remember which file backs the active custom theme. + // 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) return null; if (!Directory.Exists(_customThemesDir)) return null; - foreach (var theme in RefreshCustomCache()) - if (string.Equals(theme.Slug, slug, StringComparison.OrdinalIgnoreCase)) - return theme; + foreach (var kvp in _customCache) + { + if (string.Equals(kvp.Value.Theme.Slug, slug, StringComparison.OrdinalIgnoreCase)) + { + sourcePath = kvp.Key; + return kvp.Value.Theme; + } + } return null; } diff --git a/HellionChat/Themes/ThemeStampDiff.cs b/HellionChat/Themes/ThemeStampDiff.cs new file mode 100644 index 0000000..aa59a2d --- /dev/null +++ b/HellionChat/Themes/ThemeStampDiff.cs @@ -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; + } +} From 299fd59cbb37135705aef96a037d3d728bc9e60f Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Thu, 14 May 2026 00:00:53 +0200 Subject: [PATCH 09/13] refactor(retention): use Framework.RunOnTick instead of synchronous .Wait() Retention sweep no longer blocks for ~194ms on Framework.Run().Wait(). The clear+refilter pair is now scheduled on the next framework tick, so it still runs on the framework thread (keeping the Tabs-list mutation serialisation invariant -- Plugin.Config.Tabs is plain List and AutoTellTabsService can mutate it from background paths) but does not block the sweep thread while the framework finishes the current frame. A new _isDisposing volatile bool is set as the first statement in DisposeAsync so a deferred tick that fires after teardown bails before it touches MessageManager / Log / static fields the dispose path has already cleared. The retention worker is IsBackground=true so plugin unload can race against a still-pending tick. The existing RetentionSweepLock / RetentionSweepRunning serialisation covers the not-two-sweeps-at-once invariant; we don't add a CTS here because RunOnTick is fire-and-forget and the framework service owns the tick lifecycle. v1.4.8 B3. Coverage via in-game smoke (frame-time trace during a retention sweep run) in Task 9 -- no Build-Suite test because the suite has no FakeFramework fixture and the change is a schedule-form swap rather than new behaviour. --- HellionChat/Plugin.cs | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index e799129..35de71c 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -126,6 +126,12 @@ public sealed class Plugin : IAsyncDalamudPlugin // Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race. 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; // Cancels the v1.4.8 FTS5 bulk-insert worker on plugin teardown. The @@ -440,6 +446,12 @@ public sealed class Plugin : IAsyncDalamudPlugin if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) 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; // Unsubscribe hooks first — mirrors the hooks-last subscribe order in LoadAsync. @@ -711,15 +723,31 @@ public sealed class Plugin : IAsyncDalamudPlugin if (deleted > 0) { Log.Information($"Retention sweep deleted {deleted} expired messages."); - // Run clear+refilter on the framework thread — FilterAllTabsAsync - // is fire-and-forget and would race the next sweep cycle. - Framework - .Run(() => + // Schedule on the next framework tick to avoid the ~194ms + // hitch from blocking with .Wait() while the framework + // finishes the current frame. Tabs-list mutation must + // 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.FilterAllTabs(); - }) - .Wait(); + } + catch (Exception ex) + { + Log.Error(ex, "Retention sweep clear+refilter failed"); + } + }); } else { From 1003a88cad17a41190bb4b9c692a6d802a6f8807 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Thu, 14 May 2026 09:58:58 +0200 Subject: [PATCH 10/13] fix(messagestore): match TEXT-stored UUID form in FTS bulk insert and LoadByGuids messages.Id is declared BLOB but stored as TEXT because Microsoft.Data.Sqlite binds Guid parameters as UUID strings (UpsertMessage uses AddWithValue with a Guid). RebuildFtsIndex cast reader.GetValue(0) to byte[] and threw InvalidCastException at the first row. LoadByGuids bound byte[] params against the TEXT-stored Id and would have returned no rows once the index had built. - RebuildFtsIndex reads via GetGuid and stores ToString() in messages_fts.message_guid. - LoadByGuids parses incoming UUID strings and binds them as Guid so Microsoft.Data.Sqlite re-serialises to TEXT, matching the messages.Id storage form. - DbViewer caller variable renamed hexIds -> guidHits for clarity. --- HellionChat/MessageStore.cs | 36 ++++++++++++++++++++++-------------- HellionChat/Ui/DbViewer.cs | 4 ++-- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index c52fd60..f3acb4a 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -690,7 +690,12 @@ internal class MessageStore : IDisposable { ct.ThrowIfCancellationRequested(); - var guidBytes = (byte[])reader.GetValue(0); + // messages.Id is BLOB-typed in the schema but stored as TEXT + // because Microsoft.Data.Sqlite binds Guid parameters as UUID + // strings by default (UpsertMessage uses AddWithValue("$Id", + // message.Id)). reader.GetValue(0) therefore returns string, + // not byte[]; GetGuid parses the TEXT form regardless. + var idGuid = reader.GetGuid(0); var senderChunks = MessagePackSerializer.Deserialize>( reader.GetFieldValue(1), MsgPackOptions @@ -700,7 +705,7 @@ internal class MessageStore : IDisposable MsgPackOptions ); - pG.Value = Convert.ToHexString(guidBytes); + pG.Value = idGuid.ToString(); pS.Value = ChunkUtil.ToRawString(senderChunks); pC.Value = ChunkUtil.ToRawString(contentChunks); insert.ExecuteNonQuery(); @@ -760,24 +765,27 @@ internal class MessageStore : IDisposable } } - // Joins hex-encoded GUIDs from FullTextSearch back to Message rows. The - // primary key is BLOB, so we decode the hex back to bytes for the IN(...) - // lookup. SQLite has a hard parameter limit of 999 in default builds, so - // we chunk the input -- a 1000-hit FTS query never explodes the SELECT. - // Result ordering is not guaranteed; callers re-sort (e.g. DbViewer sorts - // by Date descending in Sub-Task 4.4). - public IReadOnlyList LoadByGuids(IReadOnlyList hexIds) + // Joins UUID strings from FullTextSearch back to Message rows. messages.Id + // is BLOB-declared in the schema but actually stored as TEXT (UUID form) + // because Microsoft.Data.Sqlite serialises Guid parameters as strings by + // default. Binding the lookup parameters as Guid keeps the same TEXT + // storage form on both sides so the IN(...) compare matches. SQLite has a + // hard parameter limit of 999 in default builds, so we chunk the input -- + // a 1000-hit FTS query never explodes the SELECT. Result ordering is not + // guaranteed; callers re-sort (e.g. DbViewer sorts by Date descending in + // Sub-Task 4.4). + public IReadOnlyList LoadByGuids(IReadOnlyList guidStrings) { - if (hexIds.Count == 0) + if (guidStrings.Count == 0) return Array.Empty(); lock (_readLock) { - var result = new List(hexIds.Count); + var result = new List(guidStrings.Count); const int chunkSize = 500; - for (var offset = 0; offset < hexIds.Count; offset += chunkSize) + for (var offset = 0; offset < guidStrings.Count; offset += chunkSize) { - var batch = hexIds.Skip(offset).Take(chunkSize).ToList(); + var batch = guidStrings.Skip(offset).Take(chunkSize).ToList(); using var cmd = Connection.CreateCommand(); var placeholders = string.Join(",", batch.Select((_, i) => $"$id{i}")); cmd.CommandText = $""" @@ -787,7 +795,7 @@ internal class MessageStore : IDisposable WHERE Id IN ({placeholders}) AND Deleted = false; """; for (var i = 0; i < batch.Count; i++) - cmd.Parameters.AddWithValue($"$id{i}", Convert.FromHexString(batch[i])); + cmd.Parameters.AddWithValue($"$id{i}", Guid.Parse(batch[i])); using var reader = cmd.ExecuteReader(); while (reader.Read()) diff --git a/HellionChat/Ui/DbViewer.cs b/HellionChat/Ui/DbViewer.cs index 6773fba..66eecb7 100644 --- a/HellionChat/Ui/DbViewer.cs +++ b/HellionChat/Ui/DbViewer.cs @@ -485,8 +485,8 @@ public class DbViewer : Window // still serves the page. if (UseFullTextSearch && Plugin.MessageManager.Store.IsFtsIndexBuilt) { - var hexIds = Plugin.MessageManager.Store.FullTextSearch(SimpleSearchTerm); - var resolved = Plugin.MessageManager.Store.LoadByGuids(hexIds); + var guidHits = Plugin.MessageManager.Store.FullTextSearch(SimpleSearchTerm); + var resolved = Plugin.MessageManager.Store.LoadByGuids(guidHits); return new ConcurrentStack(resolved.OrderByDescending(m => m.Date)); } From eecedd9f97c4a25b6c2a2255a4868b2a4f4590ac Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Thu, 14 May 2026 10:28:51 +0200 Subject: [PATCH 11/13] chore: bump version to 1.4.8, sync manifest - csproj , Plugin.cs schema-gate self-reference, repo.json (AssemblyVersion, TestingAssemblyVersion, 3x DownloadLink URLs). - README.md shield badge, version header, Project Status body. - docs/CHANGELOG.md gains a v1.4.8 section above v1.4.7. - docs/ROADMAP.md flips Next Cycle to v1.4.9 (Plugin-Load Render Polish), v1.4.8 moves into the released history above v1.4.7. - Config schema stays at v17, Migration v17 stays additive. repo.json Changelog field and HellionChat.yaml changelog block plus the new forge-posts/v1.4.8.md follow in a separate commit (slim-drop of v1.4.4 happens there). --- HellionChat/HellionChat.csproj | 2 +- HellionChat/Plugin.cs | 4 ++-- README.md | 34 +++++++++++++++------------------- docs/CHANGELOG.md | 29 +++++++++++++++++++++++++++++ docs/ROADMAP.md | 25 +++++++++++++++++++++---- repo.json | 10 +++++----- 6 files changed, 73 insertions(+), 31 deletions(-) diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index 3d11f01..14a864c 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -1,7 +1,7 @@ - 1.4.7 + 1.4.8 enable enable diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 35de71c..1006267 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -189,8 +189,8 @@ public sealed class Plugin : IAsyncDalamudPlugin if (Config.Version < 16) { throw new InvalidOperationException( - $"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." + $"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.8." ); } Config.Version = 17; diff --git a/README.md b/README.md index ebec976..a16fc3e 100644 --- a/README.md +++ b/README.md @@ -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) [![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) [![.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/) @@ -11,7 +11,7 @@ Hellion Forge

-**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). 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 -**Version 1.4.7** — Backlog cleanup and the first user-visible feature bundle since v1.4.5. TempTell tabs can now be -pinned via right-click; pinned tabs survive relog, keep their conversation history (loaded on demand from the message -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 -auto-tell pool, so the total ceiling is 20 tabs. The sidebar groups pinned tabs into their own section with its own -divider header. Honorific glow outlines now render when the title carries a Glow colour — opt-in via **Settings → -Integrations → Render glow outlines (Honorific)**, default off, so v1.4.6 visuals stay untouched for users who don't -care and the per-frame DrawList overhead is skipped on low-end hardware. Honorific gradient (Color3 / GradientColourSet -/ Wave / Pulse) is parsed and stashed for a later cycle, but currently renders as the primary colour. Sidebar width is -configurable in **Theme & Layout** between 44 and 160 px; default stays icon-only so existing users see no layout -change. `Configuration.UpdateFrom` now preserves the runtime `CurrentChannel` across the persistent-tab merge, and -`TabSwitched` deep-clones the seeded channel — together they fix a Settings-Save regression where the chat input could -pop back to `/tell ` after touching settings while 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 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). +**Version 1.4.8** — Hook-Layer and Polish Quick-Wins. The Database Viewer now has an optional FTS5 full-text search +across the entire chat history. Toggle "Full-text search" next to the search bar; the index is built asynchronously on +first run after the update with a progress toast, and the toggle stays disabled until the build completes. Multi-word +terms match as exact phrases by default; power users can opt into raw FTS5 `MATCH` syntax by wrapping their own +double-quotes. Custom theme files 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 picker click. Retention sweep no longer blocks the framework +thread (`Framework.Run(...).Wait()` 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 is postponed to v1.5.x; the investigation in this +cycle showed that the FFXIV `ContentIdResolverHook` does not fire when other plugins suppress tells via +`CheckMessageHandled`, which means tell-partner identification breaks for AutoTellTab routing — the fix lives next to +the planned ad-block hook layer where the same `RaptureLogModule` patch surface comes up anyway. Migration v17 stays +(no schema bump). Ninth sub-patch of the v1.4.x polish sweep series (as of 2026-05-14). Hellion Chat is a standalone plugin, no longer a fork in the repository sense. Fully completed: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7f440a3..0b35387 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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) Eighth sub-patch of the v1.4.x polish-sweep series. First user-visible feature bundle since v1.4.5 — pinned tell tabs diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8af91c5..539b51f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 -full-text search via SQLite FTS5, plus preparation for the later Ad-Block cycle. Hook-layer investigation is shared -across these items so they cluster naturally in one sub-patch. +**Plugin-Load Render Polish.** Erststart-Frame-Hitch (~110 ms UiBuilder) and the related Font-Atlas + Auto-Translate +warmup costs surface every load and are reproducible in `/xlstats`. The cycle also unblocks the lazy-window refactor +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. --- diff --git a/repo.json b/repo.json index 526d5ba..52446bc 100644 --- a/repo.json +++ b/repo.json @@ -3,7 +3,7 @@ "Author": "Jon Kazama (Hellion Forge)", "Name": "Hellion Chat", "InternalName": "HellionChat", - "AssemblyVersion": "1.4.7.0", + "AssemblyVersion": "1.4.8.0", "Description": "A Hellion Forge plugin — privacy-focused chat replacement for FINAL FANTASY XIV, built for EU, US and JP data rules.\n\nBy default only your own conversations are stored. Public chat, NPC dialogue, system messages and battle logs are discarded at the storage layer unless you opt in. Retention windows are configurable per channel, history can be wiped retroactively, and everything can be exported on demand.\n\nFeatures:\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with preview and Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with three profiles: Privacy-First, Casual, Full History\n- Bilingual UI (EN/DE) with live language switching\n- Own config and database — no shared state with other plugins\n\nBased on Chat 2 by Infi and Anna (EUPL-1.2).\nSupport: https://discord.gg/X9V7Kcv5gR", "ApplicableVersion": "any", "RepoUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat", @@ -16,10 +16,10 @@ "Punchline": "A Hellion Forge plugin. Privacy-first chat for FFXIV, built to stay out of your way.", "Changelog": "**v1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)**\n\nEighth 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.\n\n- 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 'Angepinnt' / 'Pinned' section in the sidebar with its own divider header\n- 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\n- Sidebar width is now configurable in Theme & Layout (range 44–160 px). Default stays icon-only; widen to fit section headers like 'Aktive Tells (3)' without truncation\n- 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\n- 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\n- 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\n- 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\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.6 — Code Hygiene and Refactor (2026-05-12)**\n\nMaintenance patch. No user-visible behaviour changes; tightens the development feedback loop, fixes two upstream-inherited bugs, and prepares the code for the v1.4.7 backlog cleanup.\n\n- preflight.sh gains a csharpier reflow check and a markdownlint pass so style drift and markdown violations are caught at the pre-push gate\n- FontManager fallback catches the full set of atlas-toolkit throws (IO, InvalidOperation, ArgumentException) — a corrupt font config no longer takes down the whole atlas build\n- BrandingLinks and IntegrationLinks URLs validated on plugin load — a typo in a future URL rotation now throws at startup\n- Cherry-picked from ChatTwo upstream f35b7d3: Chat.SetChannel no longer leaks the native Utf8String when the linkshell check rejects the channel\n- Cherry-picked from ChatTwo upstream f35b7d3: Tab.Clone now deep-clones UsedChannel and TellTarget — PopOut and Temp tabs no longer mutate each other's channel state\n- Active-tab underline scales with DPI and rounds to physical pixels for crisp rendering above 100% scaling\n- IconButton width parameter no longer subtracts HUD-scaled padding from a raw int (measured width passes through verbatim)\n- Internal: HellionStyle ChildBgAlpha extracted to a testable helper; Plugin.SaveConfig clones only the temp tabs; SettingsOverview caches the draw-list per frame; Dalamud.Utility.Util surface routed through an IPlatformUtil indirection (MessageStore IsWine probe is now testable in isolation)\n- Built-in themes: Crystal Nocturne (sapphire and electric magenta over obsidian, by CRYSTALLITE) replaces Moonlit Bloom. Users with Moonlit Bloom selected fall back to Hellion Arctic on first load\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.5 — UX and Robustness (2026-05-12)**\n\nSixth sub-patch of the v1.4.x polish-sweep series. Chat-log draw failures surface as a notification, the first-run wizard has an explicit Later option, the input history clears on plugin reload, and the status bar version slot stops clipping in narrow windows.\n\n- Chat window draw errors now show a one-shot notification instead of failing silently — stack trace stays in /xllog\n- First-run wizard: explicit \"Later — keep defaults\" button. Closing the X no longer silently accepts the defaults; the wizard reopens on the next plugin load if nothing was picked\n- InputHistoryService clears on plugin dispose so the previous session's typed commands don't bleed into the next load\n- Status bar hides the version slot when the chat window is too narrow to fit all five slots without overlap\n- Internal: explicit session-only Auto-Tell-Tab invariant in Plugin.cs plus a pinning test in the Build-Suite\n- Internal: FontManager falls back to the system font if the embedded Hellion font resource is missing — logs a Warning\n\n---\n\n**v1.4.4 — Threading and IPC safety polish (2026-05-12)**\n\nFifth 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.\n\n- AutoTellTabs hot-path getter uses an Interlocked counter instead of taking the lock on every read\n- Honorific integration: per-method threading banners, plus Warning-level log on unsubscribe failure\n- AutoTranslate warmup thread marked IsBackground so plugin unload doesn't wait for it\n- PrivacyFilter logs once per unknown ChatType so a future patch's added channel doesn't drop off the radar\n- New installs persist unknown channels by default; existing configs keep their explicit choice\n\n---\n\nFull history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases", "AcceptsFeedback": true, - "DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.7/latest.zip", - "DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.7/latest.zip", - "DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.7/latest.zip", - "TestingAssemblyVersion": "1.4.7.0", + "DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.8/latest.zip", + "DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.8/latest.zip", + "DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.8/latest.zip", + "TestingAssemblyVersion": "1.4.8.0", "IconUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png", "ImageUrls": [ "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/chatWindow.png", From 7b3676335977514e5c2ae2cd1f5c12ec2fded388 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Thu, 14 May 2026 10:51:50 +0200 Subject: [PATCH 12/13] docs: add v1.4.8 changelog and forge announcement post - HellionChat.yaml: v1.4.8 changelog block above v1.4.7, v1.4.4 dropped per slim-rule (verify-changelog-sync enforces max 4). - repo.json: Changelog field synchronised with yaml, same slim-drop. - .github/forge-posts/v1.4.8.md: bilingual announcement post (DE body, EN block resolved from yaml at workflow time). Frontmatter subtitle 32/60 chars, versionsnatur 12/40 chars, embed total ~2787/5500 chars. --- .github/forge-posts/v1.4.8.md | 21 ++++++++++++++ HellionChat/HellionChat.yaml | 54 ++++++++++++++++++++++------------- repo.json | 2 +- 3 files changed, 56 insertions(+), 21 deletions(-) create mode 100644 .github/forge-posts/v1.4.8.md diff --git a/.github/forge-posts/v1.4.8.md b/.github/forge-posts/v1.4.8.md new file mode 100644 index 0000000..1c2e487 --- /dev/null +++ b/.github/forge-posts/v1.4.8.md @@ -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. diff --git a/HellionChat/HellionChat.yaml b/HellionChat/HellionChat.yaml index 992b5f4..a81bbd2 100755 --- a/HellionChat/HellionChat.yaml +++ b/HellionChat/HellionChat.yaml @@ -35,6 +35,40 @@ tags: - Replacement - Privacy 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)** 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 diff --git a/repo.json b/repo.json index 52446bc..d5c439f 100644 --- a/repo.json +++ b/repo.json @@ -14,7 +14,7 @@ "CanUnloadAsync": false, "LoadPriority": 0, "Punchline": "A Hellion Forge plugin. Privacy-first chat for FFXIV, built to stay out of your way.", - "Changelog": "**v1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)**\n\nEighth 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.\n\n- 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 'Angepinnt' / 'Pinned' section in the sidebar with its own divider header\n- 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\n- Sidebar width is now configurable in Theme & Layout (range 44–160 px). Default stays icon-only; widen to fit section headers like 'Aktive Tells (3)' without truncation\n- 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\n- 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\n- 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\n- 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\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.6 — Code Hygiene and Refactor (2026-05-12)**\n\nMaintenance patch. No user-visible behaviour changes; tightens the development feedback loop, fixes two upstream-inherited bugs, and prepares the code for the v1.4.7 backlog cleanup.\n\n- preflight.sh gains a csharpier reflow check and a markdownlint pass so style drift and markdown violations are caught at the pre-push gate\n- FontManager fallback catches the full set of atlas-toolkit throws (IO, InvalidOperation, ArgumentException) — a corrupt font config no longer takes down the whole atlas build\n- BrandingLinks and IntegrationLinks URLs validated on plugin load — a typo in a future URL rotation now throws at startup\n- Cherry-picked from ChatTwo upstream f35b7d3: Chat.SetChannel no longer leaks the native Utf8String when the linkshell check rejects the channel\n- Cherry-picked from ChatTwo upstream f35b7d3: Tab.Clone now deep-clones UsedChannel and TellTarget — PopOut and Temp tabs no longer mutate each other's channel state\n- Active-tab underline scales with DPI and rounds to physical pixels for crisp rendering above 100% scaling\n- IconButton width parameter no longer subtracts HUD-scaled padding from a raw int (measured width passes through verbatim)\n- Internal: HellionStyle ChildBgAlpha extracted to a testable helper; Plugin.SaveConfig clones only the temp tabs; SettingsOverview caches the draw-list per frame; Dalamud.Utility.Util surface routed through an IPlatformUtil indirection (MessageStore IsWine probe is now testable in isolation)\n- Built-in themes: Crystal Nocturne (sapphire and electric magenta over obsidian, by CRYSTALLITE) replaces Moonlit Bloom. Users with Moonlit Bloom selected fall back to Hellion Arctic on first load\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.5 — UX and Robustness (2026-05-12)**\n\nSixth sub-patch of the v1.4.x polish-sweep series. Chat-log draw failures surface as a notification, the first-run wizard has an explicit Later option, the input history clears on plugin reload, and the status bar version slot stops clipping in narrow windows.\n\n- Chat window draw errors now show a one-shot notification instead of failing silently — stack trace stays in /xllog\n- First-run wizard: explicit \"Later — keep defaults\" button. Closing the X no longer silently accepts the defaults; the wizard reopens on the next plugin load if nothing was picked\n- InputHistoryService clears on plugin dispose so the previous session's typed commands don't bleed into the next load\n- Status bar hides the version slot when the chat window is too narrow to fit all five slots without overlap\n- Internal: explicit session-only Auto-Tell-Tab invariant in Plugin.cs plus a pinning test in the Build-Suite\n- Internal: FontManager falls back to the system font if the embedded Hellion font resource is missing — logs a Warning\n\n---\n\n**v1.4.4 — Threading and IPC safety polish (2026-05-12)**\n\nFifth 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.\n\n- AutoTellTabs hot-path getter uses an Interlocked counter instead of taking the lock on every read\n- Honorific integration: per-method threading banners, plus Warning-level log on unsubscribe failure\n- AutoTranslate warmup thread marked IsBackground so plugin unload doesn't wait for it\n- PrivacyFilter logs once per unknown ChatType so a future patch's added channel doesn't drop off the radar\n- New installs persist unknown channels by default; existing configs keep their explicit choice\n\n---\n\nFull history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases", + "Changelog": "**v1.4.8 — Hook-Layer and Polish Quick-Wins (2026-05-14)**\n\nNinth 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.\n\n- 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.\n- Custom theme files now auto-reload when edited while the theme is active -- no need to re-click the theme in the picker.\n- Retention sweep no longer blocks the framework thread, removing the ~194ms mini-hitch per sweep.\n- Status bar renders correctly at Windows display scaling > 100%.\n- 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.\n- 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).\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)**\n\nEighth 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.\n\n- 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 'Angepinnt' / 'Pinned' section in the sidebar with its own divider header\n- 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\n- Sidebar width is now configurable in Theme & Layout (range 44–160 px). Default stays icon-only; widen to fit section headers like 'Aktive Tells (3)' without truncation\n- 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\n- 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\n- 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\n- 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\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.6 — Code Hygiene and Refactor (2026-05-12)**\n\nMaintenance patch. No user-visible behaviour changes; tightens the development feedback loop, fixes two upstream-inherited bugs, and prepares the code for the v1.4.7 backlog cleanup.\n\n- preflight.sh gains a csharpier reflow check and a markdownlint pass so style drift and markdown violations are caught at the pre-push gate\n- FontManager fallback catches the full set of atlas-toolkit throws (IO, InvalidOperation, ArgumentException) — a corrupt font config no longer takes down the whole atlas build\n- BrandingLinks and IntegrationLinks URLs validated on plugin load — a typo in a future URL rotation now throws at startup\n- Cherry-picked from ChatTwo upstream f35b7d3: Chat.SetChannel no longer leaks the native Utf8String when the linkshell check rejects the channel\n- Cherry-picked from ChatTwo upstream f35b7d3: Tab.Clone now deep-clones UsedChannel and TellTarget — PopOut and Temp tabs no longer mutate each other's channel state\n- Active-tab underline scales with DPI and rounds to physical pixels for crisp rendering above 100% scaling\n- IconButton width parameter no longer subtracts HUD-scaled padding from a raw int (measured width passes through verbatim)\n- Internal: HellionStyle ChildBgAlpha extracted to a testable helper; Plugin.SaveConfig clones only the temp tabs; SettingsOverview caches the draw-list per frame; Dalamud.Utility.Util surface routed through an IPlatformUtil indirection (MessageStore IsWine probe is now testable in isolation)\n- Built-in themes: Crystal Nocturne (sapphire and electric magenta over obsidian, by CRYSTALLITE) replaces Moonlit Bloom. Users with Moonlit Bloom selected fall back to Hellion Arctic on first load\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.5 — UX and Robustness (2026-05-12)**\n\nSixth sub-patch of the v1.4.x polish-sweep series. Chat-log draw failures surface as a notification, the first-run wizard has an explicit Later option, the input history clears on plugin reload, and the status bar version slot stops clipping in narrow windows.\n\n- Chat window draw errors now show a one-shot notification instead of failing silently — stack trace stays in /xllog\n- First-run wizard: explicit \"Later — keep defaults\" button. Closing the X no longer silently accepts the defaults; the wizard reopens on the next plugin load if nothing was picked\n- InputHistoryService clears on plugin dispose so the previous session's typed commands don't bleed into the next load\n- Status bar hides the version slot when the chat window is too narrow to fit all five slots without overlap\n- Internal: explicit session-only Auto-Tell-Tab invariant in Plugin.cs plus a pinning test in the Build-Suite\n- Internal: FontManager falls back to the system font if the embedded Hellion font resource is missing — logs a Warning\n\n---\n\nFull history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases", "AcceptsFeedback": true, "DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.8/latest.zip", "DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.8/latest.zip", From 7542d489833e2c93964ff2479f2af5504267aba6 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Thu, 14 May 2026 11:48:40 +0200 Subject: [PATCH 13/13] perf(dbviewer): dispatch FTS filter to worker thread FullTextSearch + LoadByGuids could stall the draw thread for 100-300 ms on large databases with a popular search term. The two hot trigger sites (FTS toggle, search input) now route via TriggerFilterRefresh, which dispatches the FTS path to Task.Run; the in-memory page-filter path stays inline because it is sub-ms on the loaded page array. _ftsFilterSeq is bumped per trigger so a late worker recognises itself as stale and drops its result instead of overwriting a newer one. The date/channel and history workers already lived on Task.Run and are untouched. Surfaced during the v1.4.8 pre-tag review. --- HellionChat/Ui/DbViewer.cs | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/HellionChat/Ui/DbViewer.cs b/HellionChat/Ui/DbViewer.cs index 66eecb7..c033e9f 100644 --- a/HellionChat/Ui/DbViewer.cs +++ b/HellionChat/Ui/DbViewer.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Numerics; using System.Text; +using System.Threading; using Dalamud.Bindings.ImGui; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface; @@ -44,6 +45,10 @@ public class DbViewer : Window private bool IsProcessing; 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 string MinDateString = ""; @@ -249,7 +254,7 @@ public class DbViewer : Window using (ImRaii.Disabled(!ftsReady)) { if (ImGui.Checkbox(HellionStrings.DbViewer_FullTextToggle, ref UseFullTextSearch)) - Filtered = Filter(Messages); + TriggerFilterRefresh(); } ImGuiUtil.HelpMarker( ftsReady @@ -267,7 +272,7 @@ public class DbViewer : Window 30 ) ) - Filtered = Filter(Messages); + TriggerFilterRefresh(); // Third row @@ -471,6 +476,34 @@ 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 Filter(Message[] messages) { if (SimpleSearchTerm == "")