Commit:
7c9ab9aParent:
b3070fbAdd in-process note content caching with prefetch and preview refresh
Reopening a note now shows its full body instantly instead of re-fetching: NoteContentCache keys on Task<string> rather than string, so a prefetch and an on-demand open of the same note share one Graph request via GetOrStart instead of racing two, and a failed fetch un-caches itself so a later attempt can retry. NoteListActivity prefetches the 3 most recent notes after every bind (initial cache render and each refresh), skipping whatever's already cached so a shifted "most recent 3" only starts fetches for genuinely new notes. Also refreshes list previews as real content arrives: NoteContentCache raises ContentFetched on a successful fetch, and NoteListActivity uses it to recompute that row's preview (via the frontmatter-stripping logic extracted out of NoteViewActivity into a shared NotePreviewBuilder) and update both the adapter and NoteCacheStore - correcting previews that GetPreviewsAsync's byte-range fetch may have truncated mid-frontmatter. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/Activities/NoteListActivity.cs
+42
-0
diff --git a/src/Activities/NoteListActivity.cs b/src/Activities/NoteListActivity.cs
index 5c1c5f3..f1362e0 100644
@@ -51,6 +51,7 @@ public class NoteListActivity : AppCompatActivity
);
_graphService = new GraphService(this);
NoteContentCache.ContentFetched += OnNoteContentFetched;
// Cache renders immediately, scrolled to the bottom, before any network call.
BindItems(NoteCacheStore.Load());
@@ -58,6 +59,29 @@ public class NoteListActivity : AppCompatActivity
_ = RefreshAsync();
}
protected override void OnDestroy()
{
NoteContentCache.ContentFetched -= OnNoteContentFetched;
base.OnDestroy();
}
// Fires whenever a prefetch or an on-demand open in NoteViewActivity finishes fetching a
// note's full body - refreshes that row's preview from it, since GetPreviewsAsync's
// byte-range fetch can cut off mid-frontmatter and doesn't strip it.
private void OnNoteContentFetched(string noteId, string content)
{
var preview = NotePreviewBuilder.Build(content);
_adapter.UpdateItem(noteId, preview);
var list = NoteCacheStore.Load();
var index = list.FindIndex(e => e.Id == noteId);
if (index < 0 || list[index].Preview == preview)
return;
list[index] = list[index] with { Preview = preview };
NoteCacheStore.Save(list);
}
private async Task RefreshAsync()
{
var folderId = AppSettings.RootFolderId;
@@ -196,6 +220,7 @@ public class NoteListActivity : AppCompatActivity
var list = NoteCacheStore.Load();
list.RemoveAll(e => e.Id == entry.Id);
NoteCacheStore.Save(list);
NoteContentCache.Remove(entry.Id);
_adapter.RemoveAt(position);
}
@@ -222,5 +247,22 @@ public class NoteListActivity : AppCompatActivity
_recyclerView.ScrollToPosition(entries.Count - 1);
_hasScrolledToBottom = true;
}
PrefetchRecentNotes(entries);
}
private const int PrefetchCount = 3;
// Runs after every bind - the initial cache render and every refresh - so a newer "most
// recent 3" (e.g. a note synced in from elsewhere) gets its own prefetch too. GetOrStart
// skips any note that's already cached from a previous round, so nothing is ever
// double-fetched; this is why a session can end up with more than 3 notes prefetched.
private void PrefetchRecentNotes(List<NoteCacheEntry> entries)
{
foreach (var entry in entries.TakeLast(PrefetchCount))
{
var noteId = entry.Id;
NoteContentCache.GetOrStart(noteId, () => _graphService.GetContentAsync(noteId));
}
}
}
src/Activities/NoteViewActivity.cs
+16
-42
diff --git a/src/Activities/NoteViewActivity.cs b/src/Activities/NoteViewActivity.cs
index 470e83b..ebc9658 100644
@@ -82,7 +82,16 @@ public class NoteViewActivity : AppCompatActivity
);
if (_noteId != null)
_ = LoadContentAsync(_noteId);
{
var noteId = _noteId;
// GetOrStart reuses an already-running prefetch or a previous open's cached fetch
// instead of starting a second one - whichever's already in flight wins the race.
var contentTask = NoteContentCache.GetOrStart(
noteId,
() => _graphService.GetContentAsync(noteId)
);
_ = LoadContentAsync(contentTask);
}
}
public override bool OnCreateOptionsMenu(IMenu? menu)
@@ -147,18 +156,18 @@ public class NoteViewActivity : AppCompatActivity
Finish();
}
private async Task LoadContentAsync(string noteId)
private async Task LoadContentAsync(Task<string> contentTask)
{
_progressBar.Visibility = ViewStates.Visible;
try
{
var content = await _graphService.GetContentAsync(noteId);
var content = await contentTask;
// Don't clobber the user's typing, or a save that already completed, if this
// load was still in flight when either of those happened.
if (!_isDirty && !_savedAtLeastOnce)
{
SetContentTextSuppressed(content);
_currentPreview = BuildPreview(content);
_currentPreview = NotePreviewBuilder.Build(content);
}
}
catch (Exception ex)
@@ -191,7 +200,7 @@ public class NoteViewActivity : AppCompatActivity
folderId,
_noteName,
content,
BuildPreview(content)
NotePreviewBuilder.Build(content)
);
_noteId = created.Id;
_noteETag = created.ETag;
@@ -209,7 +218,8 @@ public class NoteViewActivity : AppCompatActivity
_size = size;
}
_currentPreview = BuildPreview(content);
NoteContentCache.Set(_noteId, content);
_currentPreview = NotePreviewBuilder.Build(content);
_isDirty = false;
_savedAtLeastOnce = true;
}
@@ -231,42 +241,6 @@ public class NoteViewActivity : AppCompatActivity
_suppressDirtyTracking = false;
}
private static string BuildPreview(string content)
{
var withoutFrontmatter = StripFrontmatter(content);
const int maxLength = 300;
var trimmed =
withoutFrontmatter.Length > maxLength
? withoutFrontmatter[..maxLength]
: withoutFrontmatter;
var lastNewline = trimmed.LastIndexOf('\n');
return lastNewline > 0 ? trimmed[..lastNewline] : trimmed;
}
private static string StripFrontmatter(string content)
{
var lines = content.Split(
["\n", "\r\n"],
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries
);
if (lines is [])
return content;
var firstLine = lines[0];
if (firstLine.Any(c => c != '-'))
return content;
for (var i = 1; i < lines.Length; i++)
{
var line = lines[i];
if (line.Length == firstLine.Length && line.All(c => c == '-'))
return string.Join('\n', lines[(i + 1)..]);
}
return content;
}
private sealed class DiscardCheckBackPressedCallback(Action onBackPressed)
: OnBackPressedCallback(true)
{
src/Adapters/NoteListAdapter.cs
+10
-0
diff --git a/src/Adapters/NoteListAdapter.cs b/src/Adapters/NoteListAdapter.cs
index cda08f3..9bab497 100644
@@ -24,6 +24,16 @@ public class NoteListAdapter(Action<NoteCacheEntry> onNoteTapped) : RecyclerView
NotifyItemRemoved(position);
}
public void UpdateItem(string noteId, string preview)
{
var index = _items.FindIndex(e => e.Id == noteId);
if (index < 0)
return;
_items[index] = _items[index] with { Preview = preview };
NotifyItemChanged(index);
}
public override RecyclerView.ViewHolder OnCreateViewHolder(ViewGroup parent, int viewType)
{
var view = LayoutInflater
src/Storage/NoteContentCache.cs
+56
-0
diff --git a/src/Storage/NoteContentCache.cs b/src/Storage/NoteContentCache.cs
new file mode 100644
index 0000000..ab1b87d
@@ -0,0 +1,56 @@
namespace NotesApp.Storage;
// Full note bodies fetched during this app process, keyed by note id. Storing the in-flight
// Task<string> itself (not just the eventual result) lets a prefetch and an on-demand open of
// the same note share one Graph request instead of racing two - whichever asks first starts
// the fetch, and both awaiters land on the same task. In-memory only, unlike NoteCacheStore's
// list metadata: full bodies for 1000+ notes aren't worth persisting to disk, and staleness
// only matters for the current process's lifetime.
public static class NoteContentCache
{
private static readonly Dictionary<string, Task<string>> Cache = new();
// Raised whenever a fetch started via GetOrStart completes successfully - whether that
// fetch was a background prefetch or an on-demand open. Listeners (the note list) use this
// to refresh a row's preview with the real content instead of the byte-range-truncated one
// from GetPreviewsAsync. Not raised by Set(), since a save's caller already knows the fresh
// preview and updates the list itself through the normal result-passback path.
public static event Action<string, string>? ContentFetched;
public static bool TryGet(string noteId, out Task<string> contentTask) =>
Cache.TryGetValue(noteId, out contentTask!);
// Returns the existing fetch for this note if one's already running or done; otherwise
// starts one via startFetch and caches it. Never starts a second fetch for the same note.
public static Task<string> GetOrStart(string noteId, Func<Task<string>> startFetch)
{
if (Cache.TryGetValue(noteId, out var existing))
return existing;
var task = Track(noteId, startFetch());
Cache[noteId] = task;
return task;
}
public static void Set(string noteId, string content) =>
Cache[noteId] = Task.FromResult(content);
public static void Remove(string noteId) => Cache.Remove(noteId);
private static async Task<string> Track(string noteId, Task<string> fetch)
{
try
{
var content = await fetch;
ContentFetched?.Invoke(noteId, content);
return content;
}
catch
{
// A cached failed task would otherwise wedge that note - every future open/prefetch
// would rethrow the same stale error instead of retrying.
Cache.Remove(noteId);
throw;
}
}
}
src/Storage/NotePreviewBuilder.cs
+45
-0
diff --git a/src/Storage/NotePreviewBuilder.cs b/src/Storage/NotePreviewBuilder.cs
new file mode 100644
index 0000000..8c085a2
@@ -0,0 +1,45 @@
namespace NotesApp.Storage;
// Shared by NoteViewActivity (recomputing its own preview after loading/saving) and
// NoteListActivity (recomputing a row's preview whenever NoteContentCache finishes fetching
// that note's full body) - both need the exact same rules, or the list and the editor would
// disagree on what a note's preview looks like.
public static class NotePreviewBuilder
{
private const int MaxLength = 300;
public static string Build(string content)
{
var withoutFrontmatter = StripFrontmatter(content);
var trimmed =
withoutFrontmatter.Length > MaxLength
? withoutFrontmatter[..MaxLength]
: withoutFrontmatter;
var lastNewline = trimmed.LastIndexOf('\n');
return lastNewline > 0 ? trimmed[..lastNewline] : trimmed;
}
private static string StripFrontmatter(string content)
{
var lines = content.Split(
["\n", "\r\n"],
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries
);
if (lines is [])
return content;
var firstLine = lines[0];
if (firstLine.Any(c => c != '-'))
return content;
for (var i = 1; i < lines.Length; i++)
{
var line = lines[i];
if (line.Length == firstLine.Length && line.All(c => c == '-'))
return string.Join('\n', lines[(i + 1)..]);
}
return content;
}
}