Skip to content

Fix/open page by slug missing md projection - #126

Merged
avelino merged 2 commits into
outlmd:mainfrom
SSamDav:fix/open-page-by-slug-missing-md-projection
Jul 5, 2026
Merged

Fix/open page by slug missing md projection#126
avelino merged 2 commits into
outlmd:mainfrom
SSamDav:fix/open-page-by-slug-missing-md-projection

Conversation

@SSamDav

@SSamDav SSamDav commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

What this PR does

When a new device joins a sync chain, the peer ops are replayed into the in-memory CRDT tree by reload_workspace, making every synced page visible in search — but only today's journal gets its .md file projected to disk. open_page_by_slug then calls build_page_viewread_page_outline, which does fs::read_to_string().unwrap_or_default(): a missing file silently returns "", parse("") produces an empty outline, and the page opens blank with no error. The fix calls apply_page_md_with_sidecar before building the view (mirroring what open_ref already does), so the .md + sidecar are written to disk on first open if absent.

How to verify

cargo test --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all -- --check

Manual smoke (iOS + macOS):

  1. On device A, create several pages with content.
  2. Pair device B (fresh install) to device A via the Devices sheet.
  3. On device B, open the page picker (Cmd+P / search) — pages from device A should appear.
  4. Tap any of those pages — they should open with their full content, not an empty body.

Related issues / docs

Closes #120

Anything reviewers should look at carefully

  • No CRDT-correctness implication: this change only adds a lazy .md projection before a read; it does not touch op generation, apply_op, or the tree structure.
  • No markdown-format implication: apply_page_md_with_sidecar is the same writer used by open_ref and the journal renderer; the output format is unchanged.
  • The projection failure is non-fatal on both clients: a warn! / eprintln! is emitted and build_page_view still runs (returning empty content as before, rather than crashing).
  • No new public API.

Out of scope for this PR

  • Proactively projecting all synced pages to disk during reload_workspace (would fix the same symptom for any navigation path, but could be expensive on large workspaces — separate issue).
  • The open_journal_for command has a similar shape but journal pages are always projected at boot by reproject_page, so it is not affected.'

@avelino avelino left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two changes are riding in one PR. The #120 fix (desktop + mobile) is the right shape and mirrors open_ref cleanly. The TUI slash-palette reorder is the problem: it's the same change as #125, which is still in review for the exact bug it still carries here.

Split it. Keep #126 as the #120 fix only, move the TUI part back to #125. Then #126 merges once the sidecar churn below is handled.

blocking

  • The TUI reorder only syncs the empty-query case. With an active query, arrow navigation still walks candidates in score order while the renderer always buckets by category, so can move the highlight up. Same bug I flagged on #125.

needs fixing

  • open_page_by_slug now projects .md + sidecar on every open. build_sidecar stamps last_synced_at: now(), so the .outl rewrites on every page open even when the .md is byte-identical. This is the hottest nav path, so it turns into sync churn. Guard it to only project when the file is missing.

No new test for #120 either. A cross-category navigation test locks the palette regression; a "synced page opens with content" test locks #120.

// so a missing file produces an empty outline — the page opens blank.
// Mirrors the `open_ref` fix.
with_ws_mut(&state, |ws| {
if let Err(e) = outl_actions::apply_page_md_with_sidecar(ws, &root, id) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This projects on every open. build_sidecar stamps last_synced_at: now(), so the .outl gets rewritten every time you open a page, even when the .md is identical. That's pure sync churn on the hottest nav path.

Guard it: only project when pages/<slug>.md is absent (resolve via page_meta + journal::page_md_path). Same fix on the mobile mirror at line 141. Worth an issue to guard open_ref the same way, it has the same unconditional projection and nobody caught the churn there.

Comment thread crates/outl-tui/src/actions/overlay.rs Outdated
@avelino avelino added kind:bug Something isn't working area:desktop outl-desktop: Tauri 2 macOS/Linux/Windows client area:mobile outl-mobile: Tauri 2 iOS/Android client labels Jul 3, 2026
When a new device joins a sync chain, peer ops are replayed into the
in-memory CRDT tree via reload_workspace. This makes every synced page
visible in search (list_pages reads the tree). However, the .md file
for those pages is never written to the new device's disk during
reload_workspace — only today's journal gets reproject_page called on
it.

open_page_by_slug then calls build_page_view, which calls
read_page_outline, which does:

    fs::read_to_string(&md_path).unwrap_or_default()

A missing file silently returns "", parse("") produces an empty
outline, and the page opens blank — no error, no blocks.

The fix mirrors what open_ref already does: call
apply_page_md_with_sidecar before building the view. This writes the
.md + sidecar to disk on first open if they are absent, so subsequent
reads find the correct content.

Affected surfaces: iOS (open_page_by_slug via picker / PageSwitcher)
and desktop (same command, same code path).

Fixes: page appears in search but shows empty body after joining a
sync chain.
Replace the unconditional apply_page_md_with_sidecar call in
open_page_by_slug (desktop + mobile) with a new guarded helper
apply_page_md_with_sidecar_if_absent that projects only when the
.md is missing from disk.

Root cause of the churn: build_sidecar stamps last_synced_at: now()
on every call, so the unconditional variant rewrote the .outl sidecar
on every page open even when the .md was byte-identical — constant
sync noise on the hottest nav path for every file-transport peer
(iCloud / Syncthing).

The guard lives in outl-actions so both clients share one owner:
  - absent .md  → project (fixes outlmd#120 — synced page opens blank)
  - present .md → no-op  (fixes the sidecar churn)

Two new unit tests in outl-actions::journal lock both behaviours:
  - apply_if_absent_projects_when_md_is_missing: page in CRDT tree,
    no .md on disk → helper writes projection → read_page_outline
    returns the peer's content, not empty.
  - apply_if_absent_is_noop_when_md_already_exists: .md present →
    returns None, sidecar bytes unchanged after the call.

Closes outlmd#120
Refs outlmd#127 (open_ref has the same unconditional projection — follow-up)
@avelino
avelino merged commit bdd5f40 into outlmd:main Jul 5, 2026
15 checks passed
avelino added a commit that referenced this pull request Jul 6, 2026
open_ref projected .md + sidecar on every call. build_sidecar stamps
last_synced_at: now(), so each ref-click rewrote the .outl file even
when the .md was byte-identical. file-transport users (iCloud /
Syncthing) see every click as a write that lands on the other device.

swap to the _if_absent variant introduced in #126: projects only when
the .md is missing (new page case still works), skips when it already
exists. desktop and mobile share the same body in outl-tauri-shared,
so the one-line change covers both clients.

Fixes #127

Signed-off-by: Avelino <31996+avelino@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:desktop outl-desktop: Tauri 2 macOS/Linux/Windows client area:mobile outl-mobile: Tauri 2 iOS/Android client kind:bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sync when previous notes already created.

2 participants