From ad31c7e4d6b7df308477024b0fe8f7ce6f19eb43 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 21 Aug 2026 22:32:15 -0400 Subject: [PATCH 1/2] Let journal entries be edited in the TUI --- internal/tui/journal.go | 147 +++++++++++++++++++--- internal/tui/journal_form.go | 77 ++++++++++++ internal/tui/journal_test.go | 233 ++++++++++++++++++++++++++++++++++- 3 files changed, 437 insertions(+), 20 deletions(-) create mode 100644 internal/tui/journal_form.go diff --git a/internal/tui/journal.go b/internal/tui/journal.go index 8499e8f46..5dfbd5a43 100644 --- a/internal/tui/journal.go +++ b/internal/tui/journal.go @@ -18,12 +18,19 @@ type journalRequestKind int const ( journalRequestNone journalRequestKind = iota journalRequestEntry + journalRequestMutation ) type journalDetailMsg struct { requestResult - body htmlutil.Markdown - images [][]byte + content string + body htmlutil.Markdown + images [][]byte +} + +type journalSavedMsg struct { + requestResult + removed bool } // --- Journal section view --- @@ -36,7 +43,10 @@ type journalView struct { topicViewport viewport.Model topicContent string + editContent string inThread bool + form *journalForm + notice string requests requestLane[journalRequestKind] } @@ -61,6 +71,7 @@ func (v *journalView) Update(msg tea.Msg) (tea.Cmd, bool) { return cmd, true } v.inThread = true + v.editContent = msg.content v.topicContent = markdown.Render(msg.body, max(v.vc.width-4, 40)) if msg.body.IsEmpty() { v.topicContent = "(empty)" @@ -80,8 +91,31 @@ func (v *journalView) Update(msg tea.Msg) (tea.Cmd, bool) { return tea.Batch(uploadCmds...), true } return nil, true + + case journalSavedMsg: + if !v.requests.accepts(msg.requestResult) { + return nil, true + } + v.requests.finish(msg.requestID) + if msg.err != nil { + if v.form != nil { + v.form.saving = false + v.form.status = errorNotice("Save failed", msg.err) + v.form.isError = true + } + return nil, true + } + v.form = nil + v.setNotice("Journal entry saved") + if msg.removed { + v.setNotice("Journal entry removed") + } + return v.requestJournalEntry(), true } + if v.form != nil { + return v.form.update(msg), true + } if v.inThread { var cmd tea.Cmd v.topicViewport, cmd = v.topicViewport.Update(msg) @@ -92,10 +126,21 @@ func (v *journalView) Update(msg tea.Msg) (tea.Cmd, bool) { } func (v *journalView) View() string { + if v.form != nil { + return v.form.view() + } + if v.notice != "" { + return v.vc.styles.title.Render(v.notice) + "\n" + v.topicViewport.View() + } return v.topicViewport.View() } -func (v *journalView) HelpBindings() []helpBinding { return nil } +func (v *journalView) HelpBindings() []helpBinding { + if v.form != nil { + return v.form.helpBindings() + } + return []helpBinding{{"e", "edit"}} +} func (v *journalView) SubnavItems() ([]navItem, int, string, bool) { label := "Journal" @@ -108,6 +153,7 @@ func (v *journalView) SubnavItems() ([]navItem, int, string, bool) { func (v *journalView) SubnavLeft() tea.Cmd { if v.dateIndex > 0 { v.dateIndex-- + v.setNotice("") return v.requestJournalEntry() } return nil @@ -116,13 +162,32 @@ func (v *journalView) SubnavLeft() tea.Cmd { func (v *journalView) SubnavRight() tea.Cmd { if v.dateIndex < len(v.dates)-1 { v.dateIndex++ + v.setNotice("") return v.requestJournalEntry() } return nil } func (v *journalView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { - // Journal always shows content in viewport + if v.form != nil { + if msg.Key().Code == tea.KeyEscape && !v.form.saving { + v.form = nil + return nil + } + cmd, submit := v.form.handleKey(msg) + if submit { + return v.saveJournalEntry() + } + return cmd + } + if msg.String() == "e" && v.inThread { + v.setNotice("") + v.form = newJournalForm(v.dates[v.dateIndex], v.editContent, v.vc.styles) + v.form.resize(v.vc.width, v.vc.height) + return v.form.init() + } + + // Journal always shows content in viewport. var cmd tea.Cmd v.topicViewport, cmd = v.topicViewport.Update(msg) return cmd @@ -131,39 +196,91 @@ func (v *journalView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { func (v *journalView) InThread() bool { return v.inThread } func (v *journalView) ExitThread() {} // no-op: journal always shows content func (v *journalView) Loading() bool { return v.requests.loading } +func (v *journalView) CapturingInput() bool { + return v.form != nil +} -// Restyle is a no-op: the journal caches plain text and Kitty placeholders, neither -// of which carries palette colors. The date list renders live. -func (v *journalView) Restyle() {} +func (v *journalView) AccountSwitchBlocked() bool { + return v.requests.kind == journalRequestMutation +} + +// Restyle hands the active palette to the form. Journal content itself is plain text +// and Kitty placeholders, so the viewport needs no repaint. +func (v *journalView) Restyle() { + if v.form != nil { + v.form.styles = v.vc.styles + } +} func (v *journalView) Resize(width, height int) { v.topicViewport.SetWidth(width) - v.topicViewport.SetHeight(height) + v.resizeViewport(height) + if v.form != nil { + v.form.resize(width, height) + } +} + +func (v *journalView) setNotice(notice string) { + v.notice = notice + v.resizeViewport(v.vc.height) +} + +func (v *journalView) resizeViewport(height int) { + if v.notice != "" { + height-- + } + v.topicViewport.SetHeight(max(height, 1)) } // --- Fetch command --- func (v *journalView) requestJournalEntry() tea.Cmd { + v.inThread = false + v.editContent = "" requestID, ctx := v.requests.begin(v.vc.ctx, journalRequestEntry) return v.fetchJournalEntry(ctx, requestID, v.dates[v.dateIndex]) } -// A day HEY has nothing for answers 204, and a day it cannot answer for reads the same -// way rather than as an error: the journal is a page of prose, and "(empty)" is what an -// empty one says. +// A day HEY has nothing for answers 204, which opens as an empty editable page. A failed +// read remains an error so the editor cannot replace an entry whose content it never saw. func (v *journalView) fetchJournalEntry(ctx context.Context, requestID uint64, date string) tea.Cmd { return func() tea.Msg { - content, err := v.vc.sdk.Journal().GetContent(ctx, date) - if err != nil || content == "" { + recording, err := v.vc.sdk.Journal().Get(ctx, date) + if err != nil { + return journalDetailMsg{requestResult: newRequestResult(requestID, err)} + } + if recording == nil { return journalDetailMsg{requestResult: newRequestResult(requestID, nil)} } + body := recording.ContentHtml + if body == "" { + body = recording.Content + } var images [][]byte if v.vc.imageRenderer.protocol() == imageProtocolKitty && v.vc.imageFetcher != nil { - images = newImageBudget().fetchImages(ctx, v.vc.imageFetcher, extractImageURLs(content)) + images = newImageBudget().fetchImages(ctx, v.vc.imageFetcher, extractImageURLs(body)) } - return journalDetailMsg{requestResult: newRequestResult(requestID, nil), body: htmlToMarkdown(content), images: images} + return journalDetailMsg{ + requestResult: newRequestResult(requestID, nil), + content: body, + body: htmlToMarkdown(body), + images: images, + } + } +} + +func (v *journalView) saveJournalEntry() tea.Cmd { + content := v.form.content() + date := v.form.date + requestID, ctx := v.requests.begin(v.vc.ctx, journalRequestMutation) + return func() tea.Msg { + _, err := v.vc.sdk.Journal().Update(ctx, date, content) + return journalSavedMsg{ + requestResult: newRequestResult(requestID, err), + removed: content == "", + } } } diff --git a/internal/tui/journal_form.go b/internal/tui/journal_form.go new file mode 100644 index 000000000..15c92e804 --- /dev/null +++ b/internal/tui/journal_form.go @@ -0,0 +1,77 @@ +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type journalForm struct { + date string + input textarea.Model + status string + isError bool + saving bool + styles styles +} + +func newJournalForm(date, content string, styles styles) *journalForm { + input := textarea.New() + input.Prompt = "" + input.ShowLineNumbers = false + input.Placeholder = "Write about your day…" + input.SetValue(content) + return &journalForm{date: date, input: input, styles: styles} +} + +func (f *journalForm) init() tea.Cmd { return f.input.Focus() } + +func (f *journalForm) resize(width, height int) { + f.input.SetWidth(max(width-4, 10)) + f.input.SetHeight(max(height-7, 3)) +} + +func (f *journalForm) content() string { + return strings.TrimSpace(f.input.Value()) +} + +func (f *journalForm) handleKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { + if f.saving { + return nil, false + } + if msg.String() == "ctrl+s" { + f.saving = true + f.status = "Saving…" + f.isError = false + return nil, true + } + return f.update(msg), false +} + +func (f *journalForm) update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + f.input, cmd = f.input.Update(msg) + return cmd +} + +func (f *journalForm) helpBindings() []helpBinding { + return []helpBinding{{"ctrl+s", "save"}, {"esc", "cancel"}} +} + +func (f *journalForm) view() string { + var b strings.Builder + b.WriteString(f.styles.title.Render("Journal · " + f.date)) + b.WriteString("\n\n") + b.WriteString(f.input.View()) + b.WriteString("\n" + styleMuted.Render("Rich formatting appears as HTML. Saving an empty entry removes it.")) + if f.status != "" { + statusStyle := styleMuted + if f.isError { + statusStyle = lipgloss.NewStyle().Foreground(colorError) + } + b.WriteString("\n" + statusStyle.Render(f.status)) + } + return b.String() +} diff --git a/internal/tui/journal_test.go b/internal/tui/journal_test.go index 52023adba..31804611e 100644 --- a/internal/tui/journal_test.go +++ b/internal/tui/journal_test.go @@ -18,7 +18,11 @@ import ( func journalWithEntry() *journalView { v := newJournalView(testVC()) v.Init() - v.Update(journalDetailMsg{requestResult: currentJournalRequest(v), body: htmlutil.ToMarkdown("

Today was great

")}) + v.Update(journalDetailMsg{ + requestResult: currentJournalRequest(v), + content: "

Today was great

", + body: htmlutil.ToMarkdown("

Today was great

"), + }) return v } @@ -55,6 +59,66 @@ func TestJournalViewInitSelectsToday(t *testing.T) { // --- Update: message routing --- +func TestJournalFetchKeepsRichContentForEditing(t *testing.T) { + heyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "content": "Today was great", + "content_html": "

Today was great

", + "type": "Calendar::JournalEntry", + }) + })) + t.Cleanup(heyServer.Close) + + client := hey.NewClient( + &hey.Config{BaseURL: heyServer.URL}, + &hey.StaticTokenProvider{Token: "test-token"}, + hey.WithMaxRetries(0), + ) + vc := testVC() + vc.ctx = context.Background() + vc.sdk = client + v := newJournalView(vc) + + loaded := v.fetchJournalEntry(vc.ctx, 1, "2026-08-19")().(journalDetailMsg) + + if loaded.content != "

Today was great

" { + t.Errorf("editable content = %q", loaded.content) + } + if loaded.body.IsEmpty() { + t.Error("rich-text body should be rendered") + } +} + +func TestJournalFailedReadDoesNotOpenEditor(t *testing.T) { + heyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "nope", http.StatusBadRequest) + })) + t.Cleanup(heyServer.Close) + + vc := testVC() + vc.ctx = context.Background() + vc.sdk = hey.NewClient( + &hey.Config{BaseURL: heyServer.URL}, + &hey.StaticTokenProvider{Token: "test-token"}, + hey.WithMaxRetries(0), + ) + v := newJournalView(vc) + cmd := v.Init() + + errCmd, consumed := v.Update(cmd()) + if !consumed || errCmd == nil { + t.Fatalf("failed read = consumed:%v error command:%v", consumed, errCmd != nil) + } + if v.inThread || v.editContent != "" { + t.Fatalf("failed read state = open:%v content:%q", v.inThread, v.editContent) + } + if cmd := v.HandleContentKey(keyPress("e")); cmd != nil || v.form != nil { + t.Fatalf("edit after failed read = cmd:%v form:%v", cmd != nil, v.form != nil) + } +} + func TestJournalTextFallbackDoesNotFetchImages(t *testing.T) { var imageRequests atomic.Int64 imageData := testPNG(t) @@ -100,7 +164,11 @@ func TestJournalViewHandlesDetailLoaded(t *testing.T) { v := newJournalView(testVC()) v.Init() // sets dateIndex to today - _, consumed := v.Update(journalDetailMsg{requestResult: currentJournalRequest(v), body: htmlutil.ToMarkdown("

Entry body

")}) + _, consumed := v.Update(journalDetailMsg{ + requestResult: currentJournalRequest(v), + content: "Entry body", + body: htmlutil.ToMarkdown("

Entry body

"), + }) if !consumed { t.Error("journalDetailMsg should be consumed") } @@ -110,6 +178,9 @@ func TestJournalViewHandlesDetailLoaded(t *testing.T) { if !v.inThread { t.Error("should be in thread after detail loaded") } + if v.editContent != "Entry body" { + t.Errorf("editable content = %q", v.editContent) + } } func TestJournalViewIgnoresStaleResponse(t *testing.T) { @@ -148,6 +219,146 @@ func TestJournalViewContentKeyScrolls(t *testing.T) { v.HandleContentKey(keyPress("up")) } +func TestJournalViewEditsSelectedDay(t *testing.T) { + v := journalWithEntry() + v.Resize(80, 30) + + if cmd := v.HandleContentKey(keyPress("e")); cmd == nil { + t.Fatal("edit should focus the journal form") + } + if !v.CapturingInput() { + t.Fatal("journal form should capture input") + } + if got := v.form.input.Value(); got != "

Today was great

" { + t.Errorf("form content = %q", got) + } + if got := v.form.date; got != v.dates[v.dateIndex] { + t.Errorf("form date = %q, want %q", got, v.dates[v.dateIndex]) + } + + v.HandleContentKey(keyPress("esc")) + if v.form != nil || v.CapturingInput() { + t.Fatal("escape should cancel the journal form") + } +} + +func TestJournalViewSavesAndRemovesEntries(t *testing.T) { + tests := []struct { + name string + input string + wantContent string + wantNotice string + }{ + {name: "save", input: " A better day\n", wantContent: "A better day", wantNotice: "Journal entry saved"}, + { + name: "preserve rich content", + input: `
Great
`, + wantContent: `
Great
`, + wantNotice: "Journal entry saved", + }, + {name: "remove", input: " \n ", wantContent: "", wantNotice: "Journal entry removed"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotContent string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Fatalf("method = %s, want PATCH", r.Method) + } + var payload struct { + CalendarJournalEntry struct { + Content string `json:"content"` + } `json:"calendar_journal_entry"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + gotContent = payload.CalendarJournalEntry.Content + if gotContent == "" { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "content": gotContent, + "content_html": "

" + gotContent + "

", + "type": "Calendar::JournalEntry", + }) + })) + t.Cleanup(server.Close) + + vc := testVC() + vc.ctx = context.Background() + vc.sdk = hey.NewClient( + &hey.Config{BaseURL: server.URL}, + &hey.StaticTokenProvider{Token: "test-token"}, + hey.WithMaxRetries(0), + ) + v := newJournalView(vc) + v.Init() + v.Update(journalDetailMsg{requestResult: currentJournalRequest(v)}) + v.HandleContentKey(keyPress("e")) + v.form.input.SetValue(tt.input) + + cmd := v.HandleContentKey(keyPress("ctrl+s")) + if cmd == nil || !v.form.saving || !v.AccountSwitchBlocked() { + t.Fatalf("save state = cmd:%v saving:%v blocked:%v", cmd != nil, v.form.saving, v.AccountSwitchBlocked()) + } + refresh, consumed := v.Update(cmd()) + if !consumed || refresh == nil { + t.Fatalf("saved message = consumed:%v refresh:%v", consumed, refresh != nil) + } + if gotContent != tt.wantContent { + t.Errorf("saved content = %q, want %q", gotContent, tt.wantContent) + } + if v.form != nil || v.notice != tt.wantNotice { + t.Errorf("saved state = form:%v notice:%q", v.form != nil, v.notice) + } + if got, want := v.topicViewport.Height(), v.vc.height-1; got != want { + t.Errorf("viewport height with notice = %d, want %d", got, want) + } + if v.requests.kind != journalRequestEntry { + t.Errorf("request kind = %v, want refresh", v.requests.kind) + } + }) + } +} + +func TestJournalViewKeepsEditorOnSaveFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + + vc := testVC() + vc.ctx = context.Background() + vc.sdk = hey.NewClient( + &hey.Config{BaseURL: server.URL}, + &hey.StaticTokenProvider{Token: "test-token"}, + hey.WithMaxRetries(0), + ) + v := newJournalView(vc) + v.Init() + v.Update(journalDetailMsg{requestResult: currentJournalRequest(v)}) + v.HandleContentKey(keyPress("e")) + v.form.input.SetValue("Keep this draft") + + cmd := v.HandleContentKey(keyPress("ctrl+s")) + v.Update(cmd()) + + if v.form == nil || v.form.saving { + t.Fatalf("failed save state = form:%v saving:%v", v.form != nil, v.form != nil && v.form.saving) + } + if v.form.input.Value() != "Keep this draft" { + t.Errorf("draft = %q", v.form.input.Value()) + } + if !v.form.isError || v.form.status == "" { + t.Errorf("failure status = error:%v status:%q", v.form.isError, v.form.status) + } +} + // --- Subnav --- func TestJournalViewSubnavItems(t *testing.T) { @@ -215,10 +426,22 @@ func TestJournalViewInThread(t *testing.T) { // --- Help bindings --- -func TestJournalViewHelpBindingsEmpty(t *testing.T) { +func TestJournalViewHelpBindings(t *testing.T) { v := journalWithEntry() bindings := v.HelpBindings() - if len(bindings) != 0 { - t.Errorf("journal should have no extra bindings, got %d", len(bindings)) + if len(bindings) != 1 || bindings[0] != (helpBinding{"e", "edit"}) { + t.Errorf("journal bindings = %#v", bindings) + } + + v.HandleContentKey(keyPress("e")) + bindings = v.HelpBindings() + want := []helpBinding{{"ctrl+s", "save"}, {"esc", "cancel"}} + if len(bindings) != len(want) { + t.Fatalf("form bindings = %#v", bindings) + } + for i := range want { + if bindings[i] != want[i] { + t.Errorf("form binding %d = %#v, want %#v", i, bindings[i], want[i]) + } } } From d02cbeb516de0e89f21cf7110d8b5a223ece3a95 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 21 Aug 2026 22:52:51 -0400 Subject: [PATCH 2/2] Keep journal editor HTML stable across saves --- internal/tui/journal.go | 81 ++++++++++++++++++++-- internal/tui/journal_test.go | 131 +++++++++++++++++++++++++++++++---- 2 files changed, 194 insertions(+), 18 deletions(-) diff --git a/internal/tui/journal.go b/internal/tui/journal.go index 5dfbd5a43..a348b5e07 100644 --- a/internal/tui/journal.go +++ b/internal/tui/journal.go @@ -2,10 +2,15 @@ package tui import ( "context" + "errors" + stdhtml "html" + "io" + "strings" "time" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" + nethtml "golang.org/x/net/html" "github.com/basecamp/hey-cli/internal/htmlutil" "github.com/basecamp/hey-cli/internal/markdown" @@ -253,24 +258,88 @@ func (v *journalView) fetchJournalEntry(ctx context.Context, requestID uint64, d return journalDetailMsg{requestResult: newRequestResult(requestID, nil)} } - body := recording.ContentHtml - if body == "" { - body = recording.Content + editableContent := recording.ContentHtml + renderedContent := recording.ContentHtml + if renderedContent == "" { + editableContent = recording.Content + renderedContent = stdhtml.EscapeString(recording.Content) + } else { + editableContent = journalEditorContent(editableContent) } var images [][]byte if v.vc.imageRenderer.protocol() == imageProtocolKitty && v.vc.imageFetcher != nil { - images = newImageBudget().fetchImages(ctx, v.vc.imageFetcher, extractImageURLs(body)) + images = newImageBudget().fetchImages(ctx, v.vc.imageFetcher, extractImageURLs(renderedContent)) } return journalDetailMsg{ requestResult: newRequestResult(requestID, nil), - content: body, - body: htmlToMarkdown(body), + content: editableContent, + body: htmlToMarkdown(renderedContent), images: images, } } } +// journalEditorContent returns a stable rich-text document for HEY updates. It removes +// div.trix-content presentation containers and writes every other token byte-for-byte, +// preserving Trix attachment attributes across repeated edit-save cycles. +func journalEditorContent(content string) string { + content = strings.TrimSpace(content) + tokens := nethtml.NewTokenizer(strings.NewReader(content)) + var result strings.Builder + var divStack []bool + for { + tokenType := tokens.Next() + if tokenType == nethtml.ErrorToken { + if errors.Is(tokens.Err(), io.EOF) { + return strings.TrimSpace(result.String()) + } + return content + } + + raw := append([]byte(nil), tokens.Raw()...) + token := tokens.Token() + switch tokenType { + case nethtml.StartTagToken: + drop := token.Data == "div" && hasHTMLClass(token, "trix-content") + if token.Data == "div" { + divStack = append(divStack, drop) + } + if !drop { + result.Write(raw) + } + case nethtml.SelfClosingTagToken: + if token.Data != "div" || !hasHTMLClass(token, "trix-content") { + result.Write(raw) + } + case nethtml.EndTagToken: + drop := false + if token.Data == "div" && len(divStack) > 0 { + drop = divStack[len(divStack)-1] + divStack = divStack[:len(divStack)-1] + } + if !drop { + result.Write(raw) + } + default: + result.Write(raw) + } + } +} + +func hasHTMLClass(token nethtml.Token, name string) bool { + for _, attribute := range token.Attr { + if attribute.Key == "class" { + for class := range strings.FieldsSeq(attribute.Val) { + if class == name { + return true + } + } + } + } + return false +} + func (v *journalView) saveJournalEntry() tea.Cmd { content := v.form.content() date := v.form.date diff --git a/internal/tui/journal_test.go b/internal/tui/journal_test.go index 31804611e..9b3c34b75 100644 --- a/internal/tui/journal_test.go +++ b/internal/tui/journal_test.go @@ -3,9 +3,9 @@ package tui import ( "context" "encoding/json" - "fmt" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" "time" @@ -65,7 +65,7 @@ func TestJournalFetchKeepsRichContentForEditing(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]any{ "id": 1, "content": "Today was great", - "content_html": "

Today was great

", + "content_html": `

Today was great

`, "type": "Calendar::JournalEntry", }) })) @@ -91,6 +91,48 @@ func TestJournalFetchKeepsRichContentForEditing(t *testing.T) { } } +func TestJournalEditorContentRemovesTrixContainers(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "read wrapper", + content: `

Today was great

`, + want: `

Today was great

`, + }, + { + name: "wrappers stored by earlier saves", + content: `
+
+
Today was great
+
+ and then some +
`, + want: "
Today was great
\n \n and then some", + }, + { + name: "attachment bytes", + content: `
`, + want: `
`, + }, + { + name: "ordinary div", + content: `
Keep me
`, + want: `
Keep me
`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := journalEditorContent(tt.content); got != tt.want { + t.Errorf("journalEditorContent() = %q, want %q", got, tt.want) + } + }) + } +} + func TestJournalFailedReadDoesNotOpenEditor(t *testing.T) { heyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "nope", http.StatusBadRequest) @@ -119,21 +161,21 @@ func TestJournalFailedReadDoesNotOpenEditor(t *testing.T) { } } -func TestJournalTextFallbackDoesNotFetchImages(t *testing.T) { +func TestJournalPlainTextFallbackStaysLiteral(t *testing.T) { var imageRequests atomic.Int64 imageData := testPNG(t) - untrusted := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - imageRequests.Add(1) - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write(imageData) - })) - t.Cleanup(untrusted.Close) - - heyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + literal := `` + heyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/rails/active_storage/blobs/") { + imageRequests.Add(1) + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageData) + return + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": 1, - "content": fmt.Sprintf(``, untrusted.URL+"/tracking.png"), + "content": literal, "type": "Calendar::JournalEntry", }) })) @@ -147,11 +189,15 @@ func TestJournalTextFallbackDoesNotFetchImages(t *testing.T) { vc := testVC() vc.ctx = context.Background() vc.sdk = client + vc.imageRenderer = kittyImageRenderer{} vc.imageFetcher = newTrustedImageFetcher(client) v := newJournalView(vc) loaded := v.fetchJournalEntry(vc.ctx, 1, "2026-08-19")().(journalDetailMsg) + if loaded.content != literal { + t.Errorf("editable content = %q, want literal %q", loaded.content, literal) + } if got := imageRequests.Load(); got != 0 { t.Fatalf("text journal fetched an image %d time(s)", got) } @@ -326,6 +372,67 @@ func TestJournalViewSavesAndRemovesEntries(t *testing.T) { } } +func TestJournalRepeatedSavesDoNotAddTrixWrapper(t *testing.T) { + stored := `
Today was great
` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "content": "Today was great", + "content_html": `
` + stored + `
`, + "type": "Calendar::JournalEntry", + }) + case http.MethodPatch: + var payload struct { + CalendarJournalEntry struct { + Content string `json:"content"` + } `json:"calendar_journal_entry"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + stored = payload.CalendarJournalEntry.Content + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "content": "Today was great", + "content_html": `
` + stored + `
`, + "type": "Calendar::JournalEntry", + }) + default: + t.Fatalf("method = %s", r.Method) + } + })) + t.Cleanup(server.Close) + + vc := testVC() + vc.ctx = context.Background() + vc.sdk = hey.NewClient( + &hey.Config{BaseURL: server.URL}, + &hey.StaticTokenProvider{Token: "test-token"}, + hey.WithMaxRetries(0), + ) + v := newJournalView(vc) + load := v.Init() + v.Update(load()) + + want := stored + for range 3 { + v.HandleContentKey(keyPress("e")) + save := v.HandleContentKey(keyPress("ctrl+s")) + refresh, _ := v.Update(save()) + v.Update(refresh()) + } + + if stored != want { + t.Errorf("stored content after repeated saves = %q, want %q", stored, want) + } + if strings.Contains(stored, "trix-content") { + t.Errorf("stored content contains a Trix wrapper: %q", stored) + } +} + func TestJournalViewKeepsEditorOnSaveFailure(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "nope", http.StatusInternalServerError)