Skip to content

Keep a link's opaque id across edits that rewrite its name - #185

Open
xVanTuring wants to merge 3 commits into
nodes-app:mainfrom
xVanTuring:fix/wikilink-id-survives-name-edits
Open

xVanTuring wants to merge 3 commits into
nodes-app:mainfrom
xVanTuring:fix/wikilink-id-survives-name-edits

Conversation

@xVanTuring

Copy link
Copy Markdown
Contributor

A link or image embed lives in the buffer as its display form — [[Name]], ![[Name]] — and the |id suffix rides along on the .wikiLinkID attribute, which makeStorageState re-attaches on every writeback. That works right up until an edit takes the attribute away and leaves nothing to rebuild it from. Three ordinary ones do, and in each the id leaves the document permanently.

For a node link that costs a link. For an image embed it costs the picture: the bytes are still in the embedder's store, but the only thing that named them was the suffix, so nothing can find them again.

The three

1. A character typed at the start of a name. makeStorageState probed .wikiLinkID at contentRange.location only. Typed text takes the base typing attributes, so that one fresh character carries no id — even though every surviving character of the name still has it — and the writeback emits a suffix-less ![[Za.png]].

2. A whole name replaced. Clicking a rendered embed selects its name (the auto-select in textViewDidChangeSelection), so the very next keystroke replaces every character the attribute sat on. Nothing survives to scan.

3. Copy, and therefore cut. copy serialized the buffer verbatim:

let raw = (string as NSString).substring(with: sel)

The clipboard gets ![[Name]] with no id. Pasting it back creates a dead reference; pasting it over the original destroys the only copy of the id. MarkdownPasteboardWriter's own doc comment already promised the private flavor round-trips [[Name|UUID]] byte-exact — it could not, because the caller handed it display text.

The fix

  • makeStorageState scans the name's attribute run (recoveredLinkID) instead of probing its first character. One lookup in the normal case, since the styler re-applies the attribute uniformly over the run.
  • shouldChangeTextIn captures the id when the replaced text was covered by a single .wikiLinkID run and the replacement carries no link grammar; textDidChange re-applies it to what landed there. ![[ / ]] markers never carry the attribute, so replacing a whole embed — or typing a fresh one over it — cannot inherit a stale id. Both guards are tested.
  • copy rebuilds the storage form from the selection's own attributes and passes it as the new optional rawMarkdown, which reaches only the private flavor. .string, HTML and RTF keep the display form, so no id leaks into another app — the property the 0.9.0 note ("side-channels no longer leak into copied text") was after.

No public API changes. MarkdownPasteboardWriter is internal, and its new parameter is defaulted.

Tests

Tests/MarkdownEngineTests/WikiLinkIDSurvivalTests.swift — 10 tests driving a real NativeTextView + coordinator through rebuildTextStorageAndStyle, so the display form and its attributes come from the engine rather than a fixture. Covers each of the three paths, the edits that already worked (insert at the end, delete inside a name), the two negative cases above, and the node-link copy.

swift build and swift test are green (513 tests).

A link or image embed lives in the buffer as its DISPLAY form (`[[Name]]`,
`![[Name]]`); the `|id` suffix sits on the `.wikiLinkID` side-channel and
`makeStorageState` re-attaches it on every writeback. Three ordinary edits
took the attribute away with nothing left to read it from, and the id then
left the document for good — for an image embed that means the picture is
gone and its stored bytes are orphaned, with no way to tell which image the
reference used to name.

- `makeStorageState` probed `.wikiLinkID` only at `contentRange.location`.
  Typed text takes the base typing attributes, so a character inserted at the
  START of a name carried no id and the probe read nil — even though every
  surviving character of the name still had it. Scan the run instead.

- Clicking a rendered embed selects its whole name, so the next keystroke
  replaces every character the attribute sat on and there is nothing left to
  scan. `shouldChangeTextIn` now captures the id when the replaced text was
  covered by one name run and the replacement carries no link grammar, and
  `textDidChange` re-applies it. `![[` / `]]` markers never carry the
  attribute, so replacing a whole embed — or typing a fresh one over it —
  cannot inherit a stale id.

- `copy` serialized the buffer verbatim, so the clipboard held `![[Name]]`
  with no id: pasting it back created a dead reference, and pasting it over
  the original destroyed the only copy of the id (cut/paste goes through the
  same path). The private raw-markdown flavor now carries the storage form,
  rebuilt from the selection's own attributes; the plain-text, HTML and RTF
  flavors keep the display form, so no id leaks into another app.
@luca-chen198

Copy link
Copy Markdown
Member

Thanks — the three paths are real: on current main, 5 of your 10 tests fail without the fix, and the guards against inheriting a stale id read right.

One blocker, one open question, one nit:

1. Paste puts the id on screen. The private flavor now carries the storage form, but paste inserts it as plain text, so the editor shows the suffix until the document is rebuilt:

copy [[Note]], paste                         buffer: [[Note|abc123]]
copy ![[pic.png]], paste into another editor  buffer: ![[pic.png|965F…|300]]

Storage is correct, the buffer isn't — cutThenPasteRoundTripsTheID only checks storage. The paste path should turn storage-form links back into display form plus .wikiLinkID, the way applyInlineReplacement does via WikiLinkService.displayFragmentAndID. Please add a buffer assertion to that test.

2. Retyping a whole node-link name now snaps back. With a resolver that knows both names: select Note in [[Note]], type Other, leave the link → [[Note|abc123]] (on main: [[Other]]). Right for image embeds; for node links it discards what the user typed. Not asking for a change yet — flagging it while we decide which way we want it.

3. Nit: wikiLinkIDToCarry computes longestEffectiveRange over the whole document on every selection replacement (autocorrect, typing or pasting over a selection). In a link-free ~800k-character note that's ~3 ms per edit. Check the attribute at the location first and only compute the range when an id is there.

Also needs a merge from main (CHANGELOG.md conflict).

`copy` puts the storage form on the private pasteboard flavor, but `paste`
inserted it as plain text, so the buffer showed `[[Note|abc123]]` until the
next rebuild. The private-flavor branch now rewrites each storage-form link
to display form and stamps its suffix on the name as `.wikiLinkID` before
insertion, so the writeback in `textDidChange` reads it. It goes through
`makeDisplayState` rather than `displayFragmentAndID`, since one paste can
carry several links. Raw source mode still pastes verbatim.

`wikiLinkIDToCarry` called `longestEffectiveRange` over the whole document
on every selection replacement; on a nil value that walks every attribute
run. Check the attribute at the location first and look up the run only
when an id is there.
@xVanTuring

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review. Pushed:

1. Paste — the private-flavor branch now rewrites storage-form links back to display form and stamps each suffix on its name as .wikiLinkID before insertion, so the writeback in textDidChange reads it. It uses makeDisplayState rather than displayFragmentAndID, since one paste can carry several links; raw source mode still pastes verbatim. cutThenPasteRoundTripsTheID now asserts the buffer too, plus tests for a node link, a cross-editor paste of ![[pic.png|<id>|300]], several links in one paste, a paste into a blockquote, and raw mode.

3. wikiLinkIDToCarry probes the attribute at the location first; the run lookup only happens when an id is there.

Merged main (CHANGELOG conflict).

On 2: my take is that retyping a whole node-link name means "point somewhere else", while for an image embed the id is the content itself. One option: carry the id over a whole-name replacement only for ![[…]] embeds, and keep carrying it for node links only when part of the name survives (the insert/delete-inside cases). Happy to do that in this PR or in a follow-up — which would you prefer?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants