diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c3e17..13233bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic ## [Unreleased] +## [0.3.4] - 2026-09-20 + +Status bar index lines no longer flash between batches. + +### Fixed + +- Status bar **Indexing** / **Indexing meaning** lines stay put between batches instead of collapsing and flashing. + ## [0.3.3] - 2026-09-20 First sync paints mail as it arrives, meaning search can use a discrete GPU, and trash, retention, and logs follow typical desktop mail apps. diff --git a/data/eu.postclient.desktop.metainfo.xml b/data/eu.postclient.desktop.metainfo.xml index 547ab90..8783736 100644 --- a/data/eu.postclient.desktop.metainfo.xml +++ b/data/eu.postclient.desktop.metainfo.xml @@ -30,6 +30,11 @@ 768 + + +

Status bar indexing and meaning-index lines stay visible between batches instead of flashing.

+
+

First sync paints mail as it arrives. Meaning search DirectML prefers a discrete GPU. Trash retention, logs, and folder menus match typical desktop mail apps.

diff --git a/src/Directory.Build.props b/src/Directory.Build.props index fac645a..f9bddac 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.3.3 + 0.3.4 Postclient Postclient Maksym Sadovnychyy diff --git a/src/MaksIT.PostClient.Client/ISemanticSearchService.cs b/src/MaksIT.PostClient.Client/ISemanticSearchService.cs index d522fa7..5752707 100644 --- a/src/MaksIT.PostClient.Client/ISemanticSearchService.cs +++ b/src/MaksIT.PostClient.Client/ISemanticSearchService.cs @@ -19,6 +19,8 @@ public interface ISemanticSearchService : IDisposable { void NotifySettingsChanged(); + void Wake(); + float[]? EmbedQuery(string query); ArchiveIndexStats IndexStats(); diff --git a/src/MaksIT.PostClient.Client/SemanticSearchService.cs b/src/MaksIT.PostClient.Client/SemanticSearchService.cs index bebb58b..e28c0c7 100644 --- a/src/MaksIT.PostClient.Client/SemanticSearchService.cs +++ b/src/MaksIT.PostClient.Client/SemanticSearchService.cs @@ -17,6 +17,7 @@ public sealed class SemanticSearchService : ISemanticSearchService { private bool _gpuFailed; private string _status = ""; private bool _ready; + private long _statusTick; public SemanticSearchService(ConfigurationFileService files, MailArchiveCatalog archive) { _files = files; @@ -51,6 +52,10 @@ public sealed class SemanticSearchService : ISemanticSearchService { public void NotifySettingsChanged() { _step?.Cancel(); + Wake(); + } + + public void Wake() { try { _wake.Release(); } @@ -189,6 +194,9 @@ public sealed class SemanticSearchService : ISemanticSearchService { var pending = _archive.PendingEmbeddings(EmbeddingModelSpec.Id, 8, ready); if (pending.Count == 0) { + await DelayAsync(TimeSpan.FromMilliseconds(800), token).ConfigureAwait(false); + if (_archive.PendingEmbeddings(EmbeddingModelSpec.Id, 1, ready).Count > 0) + continue; SetStatus("", ready: true); await WaitAsync(token).ConfigureAwait(false); return; @@ -307,11 +315,20 @@ public sealed class SemanticSearchService : ISemanticSearchService { private void SetStatus(string line, bool ready) { var nextReady = ready && _embedder is not null; + var now = Environment.TickCount64; lock (_gate) { if (_status == line && _ready == nextReady) return; + var hide = string.IsNullOrWhiteSpace(line); + if (!hide && !string.IsNullOrWhiteSpace(_status) && now - _statusTick < 250) { + _status = line; + _ready = nextReady; + return; + } + _status = line; _ready = nextReady; + _statusTick = now; } Changed?.Invoke(); diff --git a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs index bf35dd8..6ee5231 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs @@ -39,6 +39,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private const int IndexCatalogChunk = 2_000; private const int IndexUiChunk = 250; private int _indexBodyTotal; + private readonly StatusLineHold _indexHold; + private readonly StatusLineHold _semanticHold; private bool _uiReady; private MailMessageBody? _reading; private MessageRowViewModel? _pendingWindow; @@ -455,6 +457,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { _worker = worker; _semantic = semantic; _updates = updates; + _indexHold = new StatusLineHold(line => IndexLine = line); + _semanticHold = new StatusLineHold(line => SemanticLine = line); files.Current.EnsureDefaults(); PstStoreMigrator.Migrate(files, archive); MailArchiveCatalog.MigrateLegacy(files.Current.Mailboxes); @@ -512,6 +516,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { _semantic.Changed -= OnSemanticChanged; _semantic.Faulted -= OnSemanticFaulted; _semantic.Dispose(); + _indexHold.Dispose(); + _semanticHold.Dispose(); List sessions; lock (_connections) { sessions = [.. _connections.Values]; @@ -533,8 +539,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { var line = _semantic.StatusLine; if (line.Contains("ready", StringComparison.OrdinalIgnoreCase)) line = ""; - if (SemanticLine != line) - SemanticLine = line; + _semanticHold.Set(line); }); [RelayCommand] @@ -1862,7 +1867,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { ? _archive.RemoveFolder(mailboxId, folder) : _archive.RemoveUids(mailboxId, folder, uids)); if (IsInitialSyncComplete(mailboxId)) - _semantic.NotifySettingsChanged(); + _semantic.Wake(); } private static void DeleteEmlFiles(IEnumerable paths) { @@ -3172,7 +3177,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } if (ready) - _semantic.NotifySettingsChanged(); + _semantic.Wake(); } private async Task LoadMessagesAsync(string folder, CancellationToken token, bool resumeBodies = false) { @@ -3684,7 +3689,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { MailArchiveMap.BodyText(body, UnwrapEnvelope), MailArchiveMap.AttachmentIndex(body, UnwrapEnvelope)); if (IsInitialSyncComplete(mailboxId)) - _semantic.NotifySettingsChanged(); + _semantic.Wake(); } catch { } @@ -3747,7 +3752,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { _backfill?.Cancel(); _backfill?.Dispose(); _backfill = null; - IndexLine = ""; + _indexHold.ClearNow(); } private void EnsureIndexing(string mailboxId) { @@ -3777,7 +3782,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { _backfill?.Cancel(); _backfill?.Dispose(); _backfill = null; - IndexLine = ""; + _indexHold.ClearNow(); } private void StartIndexing() { @@ -3858,7 +3863,6 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { if (pending.Count == 0) { if (!cataloged) break; - await SetIndexLineAsync("", token).ConfigureAwait(false); continue; } @@ -3887,8 +3891,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } } - if (fetched == 0 && !cataloged) - break; + if (fetched == 0 && !cataloged) { + await Task.Delay(500, token).ConfigureAwait(false); + continue; + } } } catch (OperationCanceledException) { @@ -3907,10 +3913,14 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { _indexMailboxes.Remove(id); } - IndexLine = ""; - _indexBodyTotal = 0; - if (_indexMailboxes.Any(id => SessionFor(MailboxById(id)) is { IsConnected: true })) - StartIndexing(); + var again = _indexMailboxes.Any(id => SessionFor(MailboxById(id)) is { IsConnected: true }); + if (!again) { + _indexHold.Set(""); + _indexBodyTotal = 0; + return; + } + + StartIndexing(); }); } } @@ -3949,7 +3959,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private async Task SetIndexLineAsync(string line, CancellationToken token) => await UiAsync(() => { if (!token.IsCancellationRequested) - IndexLine = line; + _indexHold.Set(line); }).ConfigureAwait(false); private MailboxAccount? MailboxById(string mailboxId) => diff --git a/src/MaksIT.PostClient.UI/ViewModels/StatusLineHold.cs b/src/MaksIT.PostClient.UI/ViewModels/StatusLineHold.cs new file mode 100644 index 0000000..c17da19 --- /dev/null +++ b/src/MaksIT.PostClient.UI/ViewModels/StatusLineHold.cs @@ -0,0 +1,104 @@ +using Avalonia.Threading; + + +namespace MaksIT.PostClient.UI.ViewModels; + + +internal sealed class StatusLineHold { + private readonly Action _apply; + private readonly int _minShowMs; + private readonly int _hideMs; + private string _wanted = ""; + private string _shown = ""; + private long _shownAt; + private int _gen; + private CancellationTokenSource? _delay; + + public StatusLineHold(Action apply, int minShowMs = 250, int hideMs = 800) { + _apply = apply; + _minShowMs = minShowMs; + _hideMs = hideMs; + } + + public void Set(string line) { + _wanted = line ?? ""; + CancelDelay(); + if (!string.IsNullOrWhiteSpace(_wanted)) { + _gen++; + var now = Environment.TickCount64; + if (_shown == _wanted) + return; + if (_shown.Length > 0 && now - _shownAt < _minShowMs) { + Start(FlushLater); + return; + } + + Show(_wanted); + return; + } + + var gen = ++_gen; + Start(token => HideLater(gen, token)); + } + + public void ClearNow() { + CancelDelay(); + _gen++; + _wanted = ""; + Show(""); + } + + public void Dispose() => + CancelDelay(); + + private void Start(Func work) { + var delay = new CancellationTokenSource(); + _delay = delay; + _ = work(delay.Token); + } + + private async Task FlushLater(CancellationToken token) { + var wait = _minShowMs - (int)(Environment.TickCount64 - _shownAt); + if (wait < 0) + wait = 0; + try { + await Task.Delay(wait, token).ConfigureAwait(false); + } + catch (OperationCanceledException) { + return; + } + + await Dispatcher.UIThread.InvokeAsync(() => { + if (!string.IsNullOrWhiteSpace(_wanted) && _wanted != _shown) + Show(_wanted); + }); + } + + private async Task HideLater(int gen, CancellationToken token) { + try { + await Task.Delay(_hideMs, token).ConfigureAwait(false); + } + catch (OperationCanceledException) { + return; + } + + await Dispatcher.UIThread.InvokeAsync(() => { + if (gen != _gen) + return; + if (string.IsNullOrWhiteSpace(_wanted)) + Show(""); + }); + } + + private void Show(string line) { + _shown = line; + _shownAt = Environment.TickCount64; + _apply(line); + } + + private void CancelDelay() { + _delay?.Cancel(); + _delay?.Dispose(); + _delay = null; + } +}