Project: Write Every Change in Emacs Buffer to Disk
The mission, which I chose to accept, was to find a mode or a hook such that any change to my buffer caused Emacs to write to file. In VSCode, this was a simple setting, but in Emacs I found it a bit trickier.
My solution is currently in this github repo: frantic-save
. It amounts to some simple configuration on top of the built-in auto-save
mode.
The rest of this page are my notes about my journey to get here which is a lot more circuitous and messy than where I have ended up.
Logbook
Fri Sep 06 15:12:27 GMT-0700 (Pacific Daylight Time) 2024
While I worked on a web project which drew on a canvas, I found myself constantly following this normal web development loop:
- Make a change in my Emacs buffer
- Save the change to the file
- Alt-tab to the browser
- Refresh the page
- Look at the change
- Alt-tab back to Emacs to change something else
The loop I really wanted was only two steps:
- Make a change
- Look at the change
I felt all those extra steps as friction between me and creative fun.
I knew I could automate refreshing the page via a technology like LiveReload. And I already had my desktop windows next to each other so I could see both my editor and my browser at the same time; I only alt-tabbed between them to manually refresh. If I automated step 4 that would obviate steps 3 and 6.
But I still had that pesky intermediary step of saving the change in Emacs. The save operation in my config (evil Doom) was only three keys SPC f s, and at first I resisted the urge to reduce this "minor annoyance." But when I wanted to see the results of a one-key change, I had to press 3 more keys, and this grated on me until I had no choice but to take up the yak-shaving shears.
I had previously attempted a different kind of solution to this problem. Since I used evil-mode
, I had a fake mode to save the file each time I exited insert mode, which I got verbatim from a StackOverflow answer:
(defun my/save-on-exit-evil-insert ()
(interactive)
(add-hook 'evil-insert-state-exit-hook
(lambda ()
(call-interactively #'save-buffer))))
(defun my/stop-save-on-exit-evil-insert ()
(interactive)
(remove-hook 'evil-insert-state-exit-hook
(lambda ()
(call-interactively #'save-buffer))))
As I look at this now, I don't understand how this works. Isn't the lambda
in the remove-hook
call a different one than in the add-hook
call, and so it shouldn't exist in the hook list? But it did work, and it worked well.
Unfortunately, when I used this, I felt confused when some of my changes were saved and some weren't. For example, in my brain there's not a lot of difference between these two vim edit opertaions operations, p (paste) and i h e l l o ESC (insert "hello"). But the former still requires me to manually write to file even when I have my special hook activated. My special hook automatically writes the latter to file because I exited insert mode. For whatever reason, that difference is too much for my brain to handle, and I get frustrated.
So, my little mission for the day was to find a mode or a hook such that any change to my buffer would cause Emacs to write to file.
My first search landed me on auto-save-mode.
At first I dismissed this because it only saved a separate file. If you've ever used Emacs and found extra files with #
hash marks laying about, this is why. Spoiler alert, I came back to auto-save-mode
pretty rapidly.
Further searches were less successful. I didn't find anything after an hour of attempts with different keywords. So I went to the ever-helpful Doom Emacs Discord and asked there. Henrik, the Doom maestro himself, got back to me with some suggestions quickly.
Future: I knew there were other expensive operations in my Doom Emacs config when some buffers wrote to file. For example, apheleia-mode
would format the buffer on save, and if I was saving rapidly, that would probably cause problems. So I looked into that.
After I read the super-save
documentation suggested by Henrik, I came back to auto-save-mode
's documentation, as I realized I hadn't read it closely yet. I always wrote it off as "that thing that does backups". The info about how to control the mode suggested it had some timing logic I might want if I ended up writing my own implementation. I also later found a reference to auto-save-visited-mode
in Doom's documentation for auto-save-mode
, which it said to use "if you want to save the buffer into its visited files automatically". Wow. So maybe I could achieve what I was looking for by setting auto-save-visited-mode
with a very low auto-save-visited-interval
!
I tried this with a quick setup:
(defun my/setup-auto-save-live-mode ()
(interactive)
(auto-save-mode +1)
(auto-save-visited-mode +1)
(setq auto-save-visited-interval 0.01))
It almost achieved exactly what I wanted. But there was still a problem: it always waited about a second to save despite setting auto-save-visited-interval
to a low value. Later, I realized through testing that I just had to set the value before turning the mode on. Face palm moment! But that was later, first I moved onto different solutions.
Henrik also pointed me towards first-change-hook
, a hook executed whenever a buffer went from unchanged to changed, i.e. the first change since it was opened or saved.
(defun my/setup-auto-save-immediately ()
(interactive)
(add-hook 'first-change-hook
(lambda ()
(run-with-timer 0.01 nil #'save-buffer))))
This worked, but it had some unintended side effects. It seemed to effect every buffer. Through this I found out that I didn't understand how minor modes worked to affect only particular buffers and not others.
Another problem with this setup was that my computer almost blew up when I ran it. For some reason when I made one change, a cascade of buffer changes occurred, and that caused a string of very fast attempts to save, since every change incurred a different change.
To understand a bit more what was going on, I made my hook function print out the name of the current buffer instead of trying to save it.
(add-hook 'first-change-hook
(lambda ()
(message "first-change-hook triggered from %s" (buffer-name)))))
And low and behold, several different buffers reported in! When I looked at my *Messages*
buffer for the logs, I found:
first-change-hook triggered from article.mdx
Wrote /posts/article.mdx
"posts/article.mdx" 89L, 5957C written
first-change-hook triggered from *temp*
first-change-hook triggered from *temp file*
For some reason, when I saved this file, two temp file buffers were also edited. I figured I could diagnose what exactly was editing those temp files if I wanted to, but I wasn't very interested in that. It wasn't a problem, and I assumed it had to do with normal maintenance operations such as removing ending whitespaces. The only problem was that my hook was triggered for buffers I didn't care about. I needed to somehow not trigger my function for those other buffers, or at least stop my function before it attempted to save them.
I decided to start building a minor mode. I'd always wanted to do this, and I wondered if as I learned about how to create a minor mode, I would also learn how to fix some of the issues I found. I started by copying super-save.el
and editing it. I deleted a lot of it as my solution was a lot simpler (so far). I called it frantic-save
.
I believed I had to add a debouncing function to not save too often. Maybe I could have puzzled out a solid attempt at a debounced timer in Emacs, but I decided to look it up instead, skipping to the part of the story where I realized my silliness and learned a better way. I liked this lengthy article by Karthik Chikmagalur. I admit that I skimmed over a lot of it until I got to the elisp, but I liked the examples and diagrams and I was confident this person was going to give me the lesson I needed.
Sat Sep 7 10:29:15 AM PDT 2024
I also researched if I could reduce that idle time so that the built-in auto-save solution would work more to my liking. Maybe if I just lowered the time that "idleness" meant in Emacs, that it would just work more closely to how I wanted. So I searched for Emacs variables (in Doom SPC h v) with "idle" in the name and I found idle-update-delay
, set to 1 (second). So I tried setting it much lower, (setq idle-update-delay 0.01)
, just to see what would happen. If you're following my lead, I recommend saving all your documents before you do this!
This seemed to work, but my excitement didn't last. I realized quickly that my Emacs was just in a weird state because of my previous experiments last night. So I relearned an annoying lesson here: don't test on a system in-use. It's very easy and quick to spin up an isolated, fresh version of Emacs with only specific changes-under-test with emacs -Q
. Even though it's very convenient to run one quick test on Emacs itself as you run it, it's not good science to perform a change-test-observe loop on that same system. Also, it's relatively slow compared to a config-free Emacs.
Anyways, a quick search of this idle-update-delay
variable led me to a thread where someone asked to obsolete it because it's confusing. Well, I dropped that idea.
I decided to try to delving into the run-with-idle-timer
code to see if I could find the real definition of "idleness" in Emacs, instead of the "shotgun" method of searching for variables and trying changes to them. After delving into lots of Elisp and C code in the Emacs codebase for an hour or two, I didn't really see a reason Emacs should wait to be idle for a full second. So I went back to my original code and tried it again. It was pure dumb luck at this point that I puzzled out the issue with my first test: I accidentally ran the following code twice:
(auto-save-visited-mode +1)
(setq auto-save-visited-interval 0.0001)
The first time I ran the above code, the auto-saving exhibited the 1-second delay. And the second time I ran it (again, completely accidentally), the auto-saving was instantaneous. Oh. Face palm moment. In this moment, I realized the solution. I just needed to reverse these two lines and set the variable before activating the mode. Maybe if I wasn't writing this log I would have repressed this moment out of embarassment.
I took this back to Henrik, and he helped me understand how to make this into a more complete solution:
(defvar frantic-save-selected-buffer nil)
(setq auto-save-visited-predicate
(lambda ()
(eq (current-buffer) frantic-save-selected-buffer)))
(defun frantic-save-activate-this-buffer ()
(interactive)
(auto-save-mode +1)
(setq auto-save-visited-interval 0.0001)
(auto-save-visited-mode +1)
(setq frantic-save-selected-buffer (current-buffer))
)
(defun frantic-save-toggle-selected-buffer ()
"Toggles the frantic-save-selected-buffer as the current buffer.
Sets the frantic-save-selected-buffer to this buffer. If it was already set to
this buffer, sets it to nil instead."
(interactive)
(setq frantic-save-selected-buffer
(if (eq (current-buffer) frantic-save-selected-buffer)
nil
(current-buffer))
))
With the above code, I can call M-x frantic-save-activate-this-buffer
to start this crazy always-saving behavior, and then M-x frantic-save-toggle-selected-buffer
in the same buffer turns off the behavior, or again M-x frantic-save-toggle-selected-buffer
in a different buffer instead targets that buffer for frantic saving. This was exactly what I wanted.
Future: Later, I wanted to come back to this and review if it should be a proper mode instead.