Skip to content

Open a note in $EDITOR - #5

Open
saforem2 wants to merge 1 commit into
B33pBeeps:mainfrom
saforem2:external-editor
Open

Open a note in $EDITOR#5
saforem2 wants to merge 1 commit into
B33pBeeps:mainfrom
saforem2:external-editor

Conversation

@saforem2

Copy link
Copy Markdown

What

e on a selected note (or ctrl+e from inside the card) hands it to the
user's real editor as a markdown file, and reads it back when they quit.

export EDITOR=nvim        # or: hx, micro, "code -w", "emacsclient -nw"

The card editor is great for a few lines. Past that people want their own
editor with their own bindings, and for a note that's really a document
there's no reason redthread should be in the way.

The file

Title, blank line, body — the same first-line-is-title convention the
built-in editor and clipboard paste already use:

write rfc

- auth spec
- sync protocol

parseNoteFile delegates to the existing splitClipboardText rather than
reimplementing the split, and a test asserts the two agree across the
awkward inputs (no title, no body, leading blank lines). Writing a third
copy of that logic seemed like the obvious way to end up with three
subtly different notions of what a note's text means.

The .md extension is deliberate — it's what makes the editor turn on
markdown highlighting and wrapping.

Resolution

VISUAL beats EDITOR, per the usual convention (EDITOR may be a line
editor; VISUAL is the full-screen one). The value is treated as a command
line, not a program name, so flags survive — code -w and subl -w need
their wait flags or the editor returns instantly and the note appears
unchanged.

With neither set, e shows $EDITOR is not set rather than falling back
to vi. Dropping someone into an editor they may not know how to exit,
from a TUI they were happily using, seemed worse than saying nothing
happened.

Failure paths

The note is left exactly as it was when:

  • the editor exits non-zero (:cq)
  • the temp file can't be read, or has gone missing
  • the file comes back empty — a stray :q! on the wrong buffer
    shouldn't blank a note
  • nothing changed, so quitting without saving doesn't bump Updated

Each of those has a test; removing the empty-file guard or the exit-code
check fails one.

Testing

go test ./..., -race, vet, gofmt clean.

The interesting tests use a real subprocess rather than a mock — a
shell script that rewrites the file, and one that exits 1 — so the
exec.Cmd we hand to tea.ExecProcess is the thing under test.

I also drove the built binary end to end through tmux with a scripted
$EDITOR: pressed e, confirmed the title and body updated on the board,
that the TUI redrew correctly after the editor released the terminal, and
that the result reached notes.json. Then again with EDITOR/VISUAL
unset to see the message.

Notes

Independent of #3 and #4 — different files, any merge order works.

editorCommand splits on whitespace, so an editor path containing spaces
needs a wrapper script. That's the same limitation git has, and handling
it properly means a shell-quoting parser I don't think this earns. Easy to
add if you disagree.

The card editor is a textarea — fine for a few lines, cramped for a long
note, and it has none of the bindings people have spent years learning.
`e` on the board (or ctrl+e from inside the card) hands the note to the
user's real editor instead.

The note goes to a temp .md file as title, blank line, body — the same
first-line-is-title convention the built-in editor and clipboard paste
already use, so all three agree about what a note's text means.
parseNoteFile delegates to the existing splitter rather than
reimplementing it, and a test asserts the two stay in step. The .md
extension is deliberate: it is what turns on markdown highlighting in the
editor.

tea.ExecProcess suspends the TUI while the editor owns the terminal and
posts a message when it exits; the model reapplies the text, flashes the
note, and re-enters the alt screen.

VISUAL wins over EDITOR, per the usual convention. The value is treated
as a command line rather than a program name, so "code -w" and
"emacsclient -nw" work. With neither set, `e` says so — falling back to
vi would drop someone into an editor they may not know how to leave.

Every failure path leaves the note exactly as it was: a non-zero editor
exit (:cq), an unreadable or missing file, and a file that came back
empty. That last one matters most — a stray :q! on the wrong buffer
should not blank a note. An unchanged file is a no-op, so quitting
without saving does not bump the timestamp.

Tested with a real subprocess, not a mock: a script that rewrites the
file, and one that exits non-zero. Also driven end to end through tmux
against the built binary, confirming the suspend/resume cycle and that
the result reaches disk.
Copilot AI lite review requested due to automatic review settings August 21, 2026 13:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds an “external editor” workflow so users can open a note in their configured $VISUAL/$EDITOR as a temporary .md file, then read the result back into the app when the editor exits.

Changes:

  • Adds external-editor handoff (e on board, ctrl+e in card) via a temp .md file and tea.ExecProcess, with guarded read-back behavior.
  • Wires the new message flow into the Bubble Tea update loop and updates in-app help/footer hints.
  • Adds focused unit/integration tests for the round-trip, including real subprocess scripts.
File summaries
File Description
README.md Documents the external editor feature and keybindings.
internal/app/model.go Adds keybindings and handles externalEditDoneMsg to apply results and restore UI state.
internal/app/extedit.go Implements editor resolution, temp file write/read, and note-apply logic.
internal/app/extedit_test.go Adds tests for parsing, temp file behavior, and real subprocess editor execution.
internal/app/edit.go Updates the card footer hint to include ctrl+e.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/app/model.go
Comment on lines +261 to +263
m.setToast(toast)
// The editor owned the terminal; take the screen back.
return m, tea.Batch(tickCmd(), tea.EnterAltScreen)
Comment thread internal/app/model.go
Comment on lines +1034 to +1037
title, body := m.editor.Split()
n.Title = title
n.Body = body
cmd, why := openInEditor(n)
Comment thread README.md
Comment on lines +164 to +165
The file uses the same convention as the built-in editor: the first
non-empty line is the title, the rest is the body.
@saforem2

Copy link
Copy Markdown
Author

One thing worth knowing before this merges — a gap between this PR and #4 that neither can show on its own.

Both were written against main, so neither knows about the other. This PR applies the external edit with m.saver.Touch(), which is correct on a tree with no undo stack. Once #4 is in there is one, and the external edit is the only mutation that does not go through its m.mutate seam — so pressing u after editing a note in your editor leaves the note as the editor wrote it.

The fix is three lines: snapshot before the TUI suspends, at both entry points (e on the board and ctrl+e in the card). It has to happen before rather than in the completion handler, because by the time the editor's result comes back the note has already changed, so that is the last point where the pre-edit text still exists.

I cannot push it here yet — m.mutate does not exist on main, so it would not compile against this base. Happy to add it as soon as #4 lands, or you can take it as a follow-up; either is fine.

I found this by combining all four branches locally to check they compose. Everything else did, and the only other cross-feature issue was cosmetic (the edit-mode footer hint, which #6 replaces with a variable — worth a glance when you merge that one).

No rush on any of it.

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