(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] ## [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 ## [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. 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> <display_length compare="ge">768</display_length>
</requires> </requires>
<releases> <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"> <release version="0.3.3" date="2026-09-20">
<description> <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> <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> <LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Version>0.3.3</Version> <Version>0.3.4</Version>
<Product>Postclient</Product> <Product>Postclient</Product>
<AssemblyTitle>Postclient</AssemblyTitle> <AssemblyTitle>Postclient</AssemblyTitle>
<Authors>Maksym Sadovnychyy</Authors> <Authors>Maksym Sadovnychyy</Authors>

View File

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

View File

@ -17,6 +17,7 @@ public sealed class SemanticSearchService : ISemanticSearchService {
private bool _gpuFailed; private bool _gpuFailed;
private string _status = ""; private string _status = "";
private bool _ready; private bool _ready;
private long _statusTick;
public SemanticSearchService(ConfigurationFileService files, MailArchiveCatalog archive) { public SemanticSearchService(ConfigurationFileService files, MailArchiveCatalog archive) {
_files = files; _files = files;
@ -51,6 +52,10 @@ public sealed class SemanticSearchService : ISemanticSearchService {
public void NotifySettingsChanged() { public void NotifySettingsChanged() {
_step?.Cancel(); _step?.Cancel();
Wake();
}
public void Wake() {
try { try {
_wake.Release(); _wake.Release();
} }
@ -189,6 +194,9 @@ public sealed class SemanticSearchService : ISemanticSearchService {
var pending = _archive.PendingEmbeddings(EmbeddingModelSpec.Id, 8, ready); var pending = _archive.PendingEmbeddings(EmbeddingModelSpec.Id, 8, ready);
if (pending.Count == 0) { 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); SetStatus("", ready: true);
await WaitAsync(token).ConfigureAwait(false); await WaitAsync(token).ConfigureAwait(false);
return; return;
@ -307,11 +315,20 @@ public sealed class SemanticSearchService : ISemanticSearchService {
private void SetStatus(string line, bool ready) { private void SetStatus(string line, bool ready) {
var nextReady = ready && _embedder is not null; var nextReady = ready && _embedder is not null;
var now = Environment.TickCount64;
lock (_gate) { lock (_gate) {
if (_status == line && _ready == nextReady) if (_status == line && _ready == nextReady)
return; return;
var hide = string.IsNullOrWhiteSpace(line);
if (!hide && !string.IsNullOrWhiteSpace(_status) && now - _statusTick < 250) {
_status = line; _status = line;
_ready = nextReady; _ready = nextReady;
return;
}
_status = line;
_ready = nextReady;
_statusTick = now;
} }
Changed?.Invoke(); Changed?.Invoke();

View File

@ -39,6 +39,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private const int IndexCatalogChunk = 2_000; private const int IndexCatalogChunk = 2_000;
private const int IndexUiChunk = 250; private const int IndexUiChunk = 250;
private int _indexBodyTotal; private int _indexBodyTotal;
private readonly StatusLineHold _indexHold;
private readonly StatusLineHold _semanticHold;
private bool _uiReady; private bool _uiReady;
private MailMessageBody? _reading; private MailMessageBody? _reading;
private MessageRowViewModel? _pendingWindow; private MessageRowViewModel? _pendingWindow;
@ -455,6 +457,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
_worker = worker; _worker = worker;
_semantic = semantic; _semantic = semantic;
_updates = updates; _updates = updates;
_indexHold = new StatusLineHold(line => IndexLine = line);
_semanticHold = new StatusLineHold(line => SemanticLine = line);
files.Current.EnsureDefaults(); files.Current.EnsureDefaults();
PstStoreMigrator.Migrate(files, archive); PstStoreMigrator.Migrate(files, archive);
MailArchiveCatalog.MigrateLegacy(files.Current.Mailboxes); MailArchiveCatalog.MigrateLegacy(files.Current.Mailboxes);
@ -512,6 +516,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
_semantic.Changed -= OnSemanticChanged; _semantic.Changed -= OnSemanticChanged;
_semantic.Faulted -= OnSemanticFaulted; _semantic.Faulted -= OnSemanticFaulted;
_semantic.Dispose(); _semantic.Dispose();
_indexHold.Dispose();
_semanticHold.Dispose();
List<IMailSession> sessions; List<IMailSession> sessions;
lock (_connections) { lock (_connections) {
sessions = [.. _connections.Values]; sessions = [.. _connections.Values];
@ -533,8 +539,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
var line = _semantic.StatusLine; var line = _semantic.StatusLine;
if (line.Contains("ready", StringComparison.OrdinalIgnoreCase)) if (line.Contains("ready", StringComparison.OrdinalIgnoreCase))
line = ""; line = "";
if (SemanticLine != line) _semanticHold.Set(line);
SemanticLine = line;
}); });
[RelayCommand] [RelayCommand]
@ -1862,7 +1867,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
? _archive.RemoveFolder(mailboxId, folder) ? _archive.RemoveFolder(mailboxId, folder)
: _archive.RemoveUids(mailboxId, folder, uids)); : _archive.RemoveUids(mailboxId, folder, uids));
if (IsInitialSyncComplete(mailboxId)) if (IsInitialSyncComplete(mailboxId))
_semantic.NotifySettingsChanged(); _semantic.Wake();
} }
private static void DeleteEmlFiles(IEnumerable<string> paths) { private static void DeleteEmlFiles(IEnumerable<string> paths) {
@ -3172,7 +3177,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
} }
if (ready) if (ready)
_semantic.NotifySettingsChanged(); _semantic.Wake();
} }
private async Task LoadMessagesAsync(string folder, CancellationToken token, bool resumeBodies = false) { 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.BodyText(body, UnwrapEnvelope),
MailArchiveMap.AttachmentIndex(body, UnwrapEnvelope)); MailArchiveMap.AttachmentIndex(body, UnwrapEnvelope));
if (IsInitialSyncComplete(mailboxId)) if (IsInitialSyncComplete(mailboxId))
_semantic.NotifySettingsChanged(); _semantic.Wake();
} }
catch { catch {
} }
@ -3747,7 +3752,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
_backfill?.Cancel(); _backfill?.Cancel();
_backfill?.Dispose(); _backfill?.Dispose();
_backfill = null; _backfill = null;
IndexLine = ""; _indexHold.ClearNow();
} }
private void EnsureIndexing(string mailboxId) { private void EnsureIndexing(string mailboxId) {
@ -3777,7 +3782,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
_backfill?.Cancel(); _backfill?.Cancel();
_backfill?.Dispose(); _backfill?.Dispose();
_backfill = null; _backfill = null;
IndexLine = ""; _indexHold.ClearNow();
} }
private void StartIndexing() { private void StartIndexing() {
@ -3858,7 +3863,6 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
if (pending.Count == 0) { if (pending.Count == 0) {
if (!cataloged) if (!cataloged)
break; break;
await SetIndexLineAsync("", token).ConfigureAwait(false);
continue; continue;
} }
@ -3887,8 +3891,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
} }
} }
if (fetched == 0 && !cataloged) if (fetched == 0 && !cataloged) {
break; await Task.Delay(500, token).ConfigureAwait(false);
continue;
}
} }
} }
catch (OperationCanceledException) { catch (OperationCanceledException) {
@ -3907,9 +3913,13 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
_indexMailboxes.Remove(id); _indexMailboxes.Remove(id);
} }
IndexLine = ""; var again = _indexMailboxes.Any(id => SessionFor(MailboxById(id)) is { IsConnected: true });
if (!again) {
_indexHold.Set("");
_indexBodyTotal = 0; _indexBodyTotal = 0;
if (_indexMailboxes.Any(id => SessionFor(MailboxById(id)) is { IsConnected: true })) return;
}
StartIndexing(); StartIndexing();
}); });
} }
@ -3949,7 +3959,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private async Task SetIndexLineAsync(string line, CancellationToken token) => private async Task SetIndexLineAsync(string line, CancellationToken token) =>
await UiAsync(() => { await UiAsync(() => {
if (!token.IsCancellationRequested) if (!token.IsCancellationRequested)
IndexLine = line; _indexHold.Set(line);
}).ConfigureAwait(false); }).ConfigureAwait(false);
private MailboxAccount? MailboxById(string mailboxId) => 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;
}
}