Skip to content

fix(api): Keep handles valid and unsaved edits out of files - #1204

Merged
seflue merged 5 commits into
masterfrom
fix/api-handle-bugs
Nov 9, 2026
Merged

seflue merged 5 commits into
masterfrom
fix/api-handle-bugs

Conversation

@seflue

@seflue seflue commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Fixes five bugs in the Lua API and in how files are read and saved. Each commit adds the specs that fail without it. None of these cases had a test before, because the API specs build every handle on an unchanged file, call id_get_or_create() only on the current buffer, and never pass a list to load().

load() with a list of file names

Calling OrgApi.load() with a list of file names always returned an empty list, because each file name was compared with the whole table. It now checks whether the list contains the name, after resolving each entry to an absolute path the way a single name is resolved. Orgmode itself only loads files by a single name, so only callers outside orgmode were affected.

id_get_or_create() in another file

Calling id_get_or_create() on a headline in a file other than the current buffer returned the ID before the property was written. A link built from that ID right away pointed to a headline that did not have the ID yet. It now waits for set_property() before returning. Orgmode itself creates IDs on its internal headlines, so this also only affected callers outside orgmode.

Handles act on another headline after edits

A headline handle stops matching its headline once lines are inserted above it. reload() returned the headline at the old index, mutators put the cursor on the old start line, and refile() and get_link() used the internal headline of the old parse. So a plugin calling the API could change, move or link another headline than the one it meant, without any error.

Now a handle acts on its own headline or raises an error. It finds the headline like this:

  • With an ID, only by that ID. A missing or duplicate ID raises an error.
  • Without an ID, by the stored line number while the file has not changed since the handle was built. To tell, OrgFile gets a version that changes only when its content changes.
  • Otherwise by the outline path together with the whole headline line, including TODO keyword and tags. The outline path tells apart headlines with the same text under different parents. In this file, Project A/** Notes is different from Project B/** Notes:
    * Project A
    ** Notes
    * Project B
    ** Notes
    
    That match must have been unique when the handle was built and still be unique, or the call raises an error. Statistics cookies such as [1/2] are left out of the match, because changing the state of a child or sibling rewrites them.
  • reload() still returns nil when the headline is gone.

After a mutator:

  • It updates the handle in place and resolves with it, so one handle can be used for several calls in a row.
  • The other handles of the same load are updated too, if all of them were built from the current text and the headlines kept their order.
  • If a write hook changes the text while the change is saved, a handle without an ID follows an extmark set on its headline line before the save, as Emacs does with markers. If the hook deleted or replaced that line, the call raises an error and the change stays saved.
  • get_link() creates a missing ID through the handle, so the handle knows the ID afterwards. Like the other mutators, this saves a buffer that had no unsaved edits. id_get_or_create() reuses an ID the headline got after the handle was built.

Limitation: a handle without an ID fails when its own headline line or a parent's title is changed outside the handle between the moment the handle is built and its next use, for example when the user changes the TODO keyword in the buffer in between. That call raises an error, where master still acted on the right headline as long as no lines moved above it. This only affects a plugin that keeps a handle across user edits instead of loading it again before it acts, so I expect it to be rare. Headlines with an ID are not affected. The OrgApiHeadline docs now describe this error and how to recover: load again, find the headline and repeat the call. Extmarks would follow such edits, but every load would have to place them. This could be worth a follow-up.

Files read from disk while open in a buffer

OrgFile:reload() read a file from disk when the file on disk changed, even while the file was open in a buffer, although edits act on the buffer text. Handles, and the edits made through them, could then come from text that was not in the buffer. After the buffer was unloaded, the file kept the text of the discarded buffer, so orgmode kept working with edits the user had thrown away.

Now, for every caller of reload(), including OrgFiles:load_file() for a file that is already loaded:

  • A loaded buffer is the file content, also after it was replaced by a new buffer. Once the buffer is gone, the file is read from disk again.
  • A buffer without unsaved edits whose file changed on disk is first reloaded from disk without the usual prompt, also with noautoread, so the next save does not overwrite the change on disk. A buffer with unsaved edits is never reloaded, so no edit is lost. The user only misses the prompt. The reload runs the normal read autocmds, as :edit does.
  • OrgApi.load() reloads the files before it builds handles, so the handles come from the current text. A file that cannot be read again keeps the content it was loaded with.

Saves write unsaved edits to disk

A change made through the API, the agenda or the clock saved the whole buffer, so edits the user had not saved yet went to disk with it. For a file open in a hidden buffer, the temporary window was closed with :q!, which unloaded the buffer and threw away both the change and the user's edits.

Now a change never writes or discards the user's unsaved edits:

  • OrgFile:update() saves only a buffer that had no unsaved edits before the change. Edits that autocmds make while the change opens the buffer are saved with it. Otherwise the change stays in the buffer unsaved, and the user saves it together with their own edits.
  • A refile within the same file changes the headline level inside the change, so the whole refile is saved at once.
  • The temporary window closes without unloading the buffer. Buffers the change loaded itself get no swapfile and are unloaded again afterwards, unless they hold unsaved changes.
  • If the change fails, nothing is saved and the partial change is undone. Edits made before it stay. A change fails only on a Lua error inside the action, for example a bug or an error in an action another plugin passes in. A cancelled prompt is not a failure, and an invalid API argument fails before anything is edited. The undo is skipped if the buffer was edited while the change was pending, because it would take those edits too, or if the state before the change is gone from the undo history. The partial change then stays unsaved in the buffer, where the user can see and undo it. A buffer the change created is wiped.

This comment was marked as outdated.

This comment was marked as outdated.

This comment was marked as outdated.

This comment was marked as outdated.

This comment was marked as outdated.

Loading files by a list of names always returned an empty list,
because each file name was compared with the whole list. Orgmode
itself only loads files by a single name, so only callers outside
orgmode were affected.

Match file names against the list entries, after resolving each
entry to an absolute path the way a single file name is resolved.
Creating an ID through a headline handle, for a file that is not the
current buffer, returned the ID before the property was written. A
link made from that ID right away pointed to a headline that did not
have the ID yet. Orgmode itself creates IDs on its internal headlines,
so only callers outside orgmode were affected.

Wait for the property to be written before returning the ID.

This comment was marked as outdated.

This comment was marked as outdated.

@seflue
seflue force-pushed the fix/api-handle-bugs branch from 20c0663 to cc5c466 Compare November 3, 2026 07:05
@seflue
seflue force-pushed the fix/api-handle-bugs branch 2 times, most recently from 5333ccc to 5c528d1 Compare November 3, 2026 07:21
@seflue
seflue requested a balanced review from Copilot November 3, 2026 07:23

This comment was marked as outdated.

This comment was marked as outdated.

This comment was marked as outdated.

This comment was marked as outdated.

@seflue
seflue force-pushed the fix/api-handle-bugs branch from cb0e54f to 4eb7693 Compare November 8, 2026 19:08
@seflue
seflue requested a balanced review from Copilot November 8, 2026 19:08

This comment was marked as outdated.

@seflue
seflue requested a balanced review from Copilot November 8, 2026 19:09

This comment was marked as off-topic.

@seflue
seflue requested a review from kristijanhusak November 8, 2026 19:14
A headline handle found its headline again by index or line number.
After lines were added above it, reload returned another headline and
mutators changed another headline.

A handle with an ID now finds its headline only by that ID. Without an
ID, it uses:
- the line number while the file is unchanged since the handle was
  built,
- otherwise the outline path and headline text, leaving out statistics
  cookies, which change when child headlines change.

When the headline cannot be found unambiguously, the call raises an
error instead of acting on a guess. Refile, links and ID creation use
the same lookup. Each mutator refreshes its handle, so it can be used
for several calls. If a write hook changes the text during the save, a
handle without an ID follows an extmark set on its headline line.
A file open in a buffer was read from disk when the file on disk
changed, although edits act on the buffer text. After the buffer was
unloaded, the file kept the text of the discarded buffer.

While a buffer is loaded, its text is the file content, also after
the buffer was replaced by a new one. A buffer without unsaved edits
is first read again when the file on disk changed, without asking.
Once the buffer is gone, the file is read from disk again. Loading
all files through the API reloads them first, so their handles are
built from the current text, and keeps the loaded content of a file
that cannot be read again.
Changing a file from the API, the agenda or the clock saved the whole
buffer. If the buffer already had unsaved edits, they were written to
disk with the change. Closing the temporary window unloaded a hidden
buffer and discarded the change and the user's edits.

Save only buffers that had no unsaved edits before the change. Close
the temporary window without unloading the buffer. A refile within the
same file changes the headline level in the same step, so the level
change is saved with the move. A change that throws or rejects is
never saved. It is undone, unless the buffer was edited while the
change was pending or the undo history no longer reaches back before
it. Then it stays unsaved in the buffer, where the user can see and
undo it.
@seflue
seflue force-pushed the fix/api-handle-bugs branch from 4eb7693 to 4fbc097 Compare November 8, 2026 19:41
@seflue
seflue merged commit cd789ec into master Nov 9, 2026
26 of 27 checks passed
@seflue
seflue deleted the fix/api-handle-bugs branch November 9, 2026 00:03
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.

3 participants