Skip to content

Commit 10c805d

Browse files
committed
feat(tui): add 'e' shortcut to edit bean in external editor
- Add 'e' keybinding to list and detail views to open bean in editor - Implement editor detection with fallback chain: $VISUAL β†’ $EDITOR β†’ nano β†’ vi - Use tea.ExecProcess to suspend TUI while editor is open - File watcher automatically refreshes when bean file changes
1 parent 7dd9fba commit 10c805d

File tree

4 files changed

+86
-5
lines changed

4 files changed

+86
-5
lines changed
Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
11
---
22
title: 'TUI: ''e'' shortcut to edit bean in $EDITOR'
3-
status: todo
3+
status: completed
44
type: feature
5+
priority: normal
56
created_at: 2025-12-13T00:25:45Z
6-
updated_at: 2025-12-13T00:25:45Z
7+
updated_at: 2025-12-13T00:38:09Z
78
parent: beans-xnp8
89
---
910

10-
Add an 'e' keyboard shortcut to the TUI that opens the selected bean's markdown file in the user's $EDITOR.
11+
Add an 'e' keyboard shortcut to the TUI that opens the selected bean's markdown file in an external editor.
1112

12-
This enables full editing of the bean (title, body, frontmatter) without leaving the TUI workflow. When the editor closes, the TUI should detect the file change and refresh.
13+
This enables full editing of the bean (title, body, frontmatter) without leaving the TUI workflow. When the editor closes, the TUI should detect the file change and refresh.
14+
15+
## Editor Detection
16+
17+
Use this fallback chain:
18+
1. `$VISUAL`
19+
2. `$EDITOR`
20+
3. `nano`
21+
4. `vi`
22+
23+
## Implementation Notes
24+
25+
- Suspend the TUI while the editor is open (use tea.ExecProcess or similar)
26+
- The file watcher should already handle refreshing when the file changes
27+
- Add 'e' keybinding to both list and detail views

β€Žinternal/tui/detail.goβ€Ž

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,15 @@ func (m detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) {
355355
currentBlocking: m.bean.Blocking,
356356
}
357357
}
358+
359+
case "e":
360+
// Open editor for this bean
361+
return m, func() tea.Msg {
362+
return openEditorMsg{
363+
beanID: m.bean.ID,
364+
beanPath: m.bean.Path,
365+
}
366+
}
358367
}
359368
}
360369

@@ -423,7 +432,8 @@ func (m detailModel) View() string {
423432
}
424433
footer += helpKeyStyle.Render("enter") + " " + helpStyle.Render("go to") + " "
425434
}
426-
footer += helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
435+
footer += helpKeyStyle.Render("e") + " " + helpStyle.Render("edit") + " " +
436+
helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
427437
helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " +
428438
helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " +
429439
helpKeyStyle.Render("b") + " " + helpStyle.Render("blocking") + " " +

β€Žinternal/tui/list.goβ€Ž

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,16 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
307307
return m, func() tea.Msg {
308308
return openCreateModalMsg{}
309309
}
310+
case "e":
311+
// Open editor for selected bean
312+
if item, ok := m.list.SelectedItem().(beanItem); ok {
313+
return m, func() tea.Msg {
314+
return openEditorMsg{
315+
beanID: item.bean.ID,
316+
beanPath: item.bean.Path,
317+
}
318+
}
319+
}
310320
case "esc", "backspace":
311321
// If we have an active filter, clear it instead of quitting
312322
if m.hasActiveFilter() {
@@ -365,6 +375,7 @@ func (m listModel) View() string {
365375
if m.hasActiveFilter() {
366376
help = helpKeyStyle.Render("enter") + " " + helpStyle.Render("view") + " " +
367377
helpKeyStyle.Render("c") + " " + helpStyle.Render("create") + " " +
378+
helpKeyStyle.Render("e") + " " + helpStyle.Render("edit") + " " +
368379
helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
369380
helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " +
370381
helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " +
@@ -374,6 +385,7 @@ func (m listModel) View() string {
374385
} else {
375386
help = helpKeyStyle.Render("enter") + " " + helpStyle.Render("view") + " " +
376387
helpKeyStyle.Render("c") + " " + helpStyle.Render("create") + " " +
388+
helpKeyStyle.Render("e") + " " + helpStyle.Render("edit") + " " +
377389
helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
378390
helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " +
379391
helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " +

β€Žinternal/tui/tui.goβ€Ž

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package tui
22

33
import (
44
"context"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
58

69
tea "github.com/charmbracelet/bubbletea"
710
"github.com/hmans/beans/internal/beancore"
@@ -38,6 +41,17 @@ type tagSelectedMsg struct {
3841
// clearFilterMsg is sent to clear any active filter
3942
type clearFilterMsg struct{}
4043

44+
// openEditorMsg requests opening the editor for a bean
45+
type openEditorMsg struct {
46+
beanID string
47+
beanPath string
48+
}
49+
50+
// editorFinishedMsg is sent when the editor closes
51+
type editorFinishedMsg struct {
52+
err error
53+
}
54+
4155
// openParentPickerMsg requests opening the parent picker for a bean
4256
type openParentPickerMsg struct {
4357
beanID string
@@ -307,6 +321,20 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
307321
a.state = viewList
308322
return a, a.list.loadBeans
309323

324+
case openEditorMsg:
325+
// Launch editor for the bean file
326+
editor := getEditor()
327+
fullPath := filepath.Join(a.core.Root(), msg.beanPath)
328+
c := exec.Command(editor, fullPath)
329+
return a, tea.ExecProcess(c, func(err error) tea.Msg {
330+
return editorFinishedMsg{err: err}
331+
})
332+
333+
case editorFinishedMsg:
334+
// Editor closed - the file watcher will handle refreshing
335+
// Just return to trigger a re-render
336+
return a, nil
337+
310338
case parentSelectedMsg:
311339
// Set the new parent via GraphQL mutation
312340
var parentID *string
@@ -434,6 +462,22 @@ func (a *App) getBackgroundView() string {
434462
}
435463
}
436464

465+
// getEditor returns the user's preferred editor using the fallback chain:
466+
// $VISUAL -> $EDITOR -> nano -> vi
467+
func getEditor() string {
468+
if editor := os.Getenv("VISUAL"); editor != "" {
469+
return editor
470+
}
471+
if editor := os.Getenv("EDITOR"); editor != "" {
472+
return editor
473+
}
474+
// Fallback chain: nano is more user-friendly, vi is more universal
475+
if _, err := exec.LookPath("nano"); err == nil {
476+
return "nano"
477+
}
478+
return "vi"
479+
}
480+
437481
// Run starts the TUI application with file watching
438482
func Run(core *beancore.Core, cfg *config.Config) error {
439483
app := New(core, cfg)

0 commit comments

Comments
Β (0)