(bugfix): keep index status lines from flashing between batches

This commit is contained in:
Maksym Sadovnychyy 2026-09-20 17:45:27 +02:00
parent 92c997513b
commit 7198014a21
7 changed files with 162 additions and 16 deletions

View File

@ -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.

View File

@ -30,6 +30,11 @@
<display_length compare="ge">768</display_length>
</requires>
<releases>
<release version="0.3.4" date="2026-09-20">
<description>
<p>Status bar indexing and meaning-index lines stay visible between batches instead of flashing.</p>
</description>
</release>
<release version="0.3.3" date="2026-09-20">
<description>
<p>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.</p>

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.3.3</Version>
<Version>0.3.4</Version>
<Product>Postclient</Product>
<AssemblyTitle>Postclient</AssemblyTitle>
<Authors>Maksym Sadovnychyy</Authors>

View File

@ -19,6 +19,8 @@ public interface ISemanticSearchService : IDisposable {
void NotifySettingsChanged();
void Wake();
float[]? EmbedQuery(string query);
ArchiveIndexStats IndexStats();

View File

@ -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();

View File

@ -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<IMailSession> 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<string> 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) =>

View File

@ -0,0 +1,104 @@
using Avalonia.Threading;
namespace MaksIT.PostClient.UI.ViewModels;
internal sealed class StatusLineHold {
private readonly Action<string> _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<string> 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<CancellationToken, Task> 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;
}
}