Updated the custom message area to be a text area instead for better text editing
Container image / image (push) Successful in 1m36s
Release / release (push) Successful in 1m41s

This commit is contained in:
2026-05-15 06:41:10 +00:00
parent e48da30240
commit 005be12dd1
+141 -39
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -51,10 +52,45 @@ func modalDoneCmd(flash string, level flashLevel) tea.Cmd {
// ============================================================= // =============================================================
type formField struct { type formField struct {
label string label string
input textinput.Model input textinput.Model
required bool textarea textarea.Model
hint string multiline bool
required bool
hint string
}
// value returns the field's current text regardless of whether it's
// backed by a single-line input or a multiline textarea.
func (fld *formField) value() string {
if fld.multiline {
return fld.textarea.Value()
}
return fld.input.Value()
}
func (fld *formField) focus() {
if fld.multiline {
fld.textarea.Focus()
return
}
fld.input.Focus()
}
func (fld *formField) blur() {
if fld.multiline {
fld.textarea.Blur()
return
}
fld.input.Blur()
}
func (fld *formField) setWidth(w int) {
if fld.multiline {
fld.textarea.SetWidth(w)
return
}
fld.input.Width = w
} }
type form struct { type form struct {
@@ -86,12 +122,14 @@ func fieldWidthFor(termWidth int) int {
func newForm(title string, fields []formField, submit func([]string) tea.Cmd) *form { func newForm(title string, fields []formField, submit func([]string) tea.Cmd) *form {
for i := range fields { for i := range fields {
fields[i].input.Prompt = "" if !fields[i].multiline {
fields[i].input.CharLimit = 256 fields[i].input.Prompt = ""
fields[i].input.CharLimit = 256
}
if i == 0 { if i == 0 {
fields[i].input.Focus() fields[i].focus()
} else { } else {
fields[i].input.Blur() fields[i].blur()
} }
} }
return &form{title: title, fields: fields, submit: submit} return &form{title: title, fields: fields, submit: submit}
@@ -114,6 +152,31 @@ func textFieldWithValue(label, hint, value string, required bool) formField {
return formField{label: label, hint: hint, required: required, input: ti} return formField{label: label, hint: hint, required: required, input: ti}
} }
// textAreaField creates a multiline field. Enter inserts a newline;
// the form uses shift+enter / ctrl+s to submit when the cursor is on
// one of these. Useful for things like alert body templates where the
// rendered message naturally spans multiple lines.
func textAreaField(label, hint string, required bool) formField {
return textAreaFieldWithValue(label, hint, "", required)
}
func textAreaFieldWithValue(label, hint, value string, required bool) formField {
ta := textarea.New()
ta.Placeholder = hint
ta.ShowLineNumbers = false
ta.Prompt = " "
ta.SetHeight(5)
ta.SetWidth(defaultFieldWidth)
ta.CharLimit = 0
// Keep enter bound to "insert newline" (the textarea default) — the
// surrounding form intercepts enter on single-line fields and handles
// shift+enter/ctrl+s as the submit/advance trigger for multiline ones.
if value != "" {
ta.SetValue(value)
}
return formField{label: label, hint: hint, required: required, multiline: true, textarea: ta}
}
func passwordField(label, hint string) formField { func passwordField(label, hint string) formField {
return passwordFieldWithValue(label, hint, "") return passwordFieldWithValue(label, hint, "")
} }
@@ -146,7 +209,11 @@ func (f *form) View() string {
labelStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) labelStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
} }
fmt.Fprintf(&b, "%s%s\n", marker, labelStyle.Render(fld.label)) fmt.Fprintf(&b, "%s%s\n", marker, labelStyle.Render(fld.label))
fmt.Fprintf(&b, " %s\n", fld.input.View()) if fld.multiline {
fmt.Fprintf(&b, "%s\n", fld.textarea.View())
} else {
fmt.Fprintf(&b, " %s\n", fld.input.View())
}
if i == f.cursor && fld.hint != "" { if i == f.cursor && fld.hint != "" {
fmt.Fprintf(&b, " %s\n", helpStyle.Render(fld.hint)) fmt.Fprintf(&b, " %s\n", helpStyle.Render(fld.hint))
} }
@@ -158,7 +225,11 @@ func (f *form) View() string {
if f.busy { if f.busy {
fmt.Fprintf(&b, "%s\n", flashWarnStyle.Render("working…")) fmt.Fprintf(&b, "%s\n", flashWarnStyle.Render("working…"))
} else { } else {
fmt.Fprintf(&b, "%s\n", helpStyle.Render("↑↓ field enter next/submit esc cancel")) help := "↑↓ field enter next/submit esc cancel"
if f.cursor < len(f.fields) && f.fields[f.cursor].multiline {
help = "tab field enter newline shift+enter/ctrl+s submit esc cancel"
}
fmt.Fprintf(&b, "%s\n", helpStyle.Render(help))
} }
return b.String() return b.String()
} }
@@ -169,7 +240,7 @@ func (f *form) Update(msg tea.Msg) (modal, tea.Cmd) {
f.width = msg.Width f.width = msg.Width
w := fieldWidthFor(msg.Width) w := fieldWidthFor(msg.Width)
for i := range f.fields { for i := range f.fields {
f.fields[i].input.Width = w f.fields[i].setWidth(w)
} }
return f, nil return f, nil
@@ -179,45 +250,76 @@ func (f *form) Update(msg tea.Msg) (modal, tea.Cmd) {
return f, nil return f, nil
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { key := msg.String()
// up/down on a multiline field belong to in-text navigation;
// leave field-switching to tab/shift+tab there. Same for enter:
// the textarea owns it as "insert newline", so submission moves
// to shift+enter / ctrl+s.
multiline := f.cursor < len(f.fields) && f.fields[f.cursor].multiline
switch key {
case "esc": case "esc":
return f, modalDoneCmd("", flashInfo) return f, modalDoneCmd("", flashInfo)
case "tab", "down": case "tab":
f.advance(1) f.advance(1)
return f, nil return f, nil
case "shift+tab", "up": case "shift+tab":
f.advance(-1) f.advance(-1)
return f, nil return f, nil
case "enter": case "down":
if f.busy { if !multiline {
return f, nil
}
if f.cursor < len(f.fields)-1 {
f.advance(1) f.advance(1)
return f, nil return f, nil
} }
vals := make([]string, len(f.fields)) case "up":
for i, fld := range f.fields { if !multiline {
vals[i] = fld.input.Value() f.advance(-1)
return f, nil
} }
for i, fld := range f.fields { case "enter":
if fld.required && strings.TrimSpace(vals[i]) == "" { if !multiline {
f.err = fld.label + " is required" return f, f.submitOrAdvance()
f.cursor = i
f.focusOnly(i)
return f, nil
}
} }
f.busy = true case "shift+enter", "ctrl+s":
f.err = "" return f, f.submitOrAdvance()
return f, f.submit(vals)
} }
} }
var cmd tea.Cmd var cmd tea.Cmd
f.fields[f.cursor].input, cmd = f.fields[f.cursor].input.Update(msg) if f.fields[f.cursor].multiline {
f.fields[f.cursor].textarea, cmd = f.fields[f.cursor].textarea.Update(msg)
} else {
f.fields[f.cursor].input, cmd = f.fields[f.cursor].input.Update(msg)
}
return f, cmd return f, cmd
} }
// submitOrAdvance is the shared trigger for enter on single-line fields
// and shift+enter / ctrl+s on multiline fields: jump to the next field
// or, on the last one, validate and run submit.
func (f *form) submitOrAdvance() tea.Cmd {
if f.busy {
return nil
}
if f.cursor < len(f.fields)-1 {
f.advance(1)
return nil
}
vals := make([]string, len(f.fields))
for i := range f.fields {
vals[i] = f.fields[i].value()
}
for i, fld := range f.fields {
if fld.required && strings.TrimSpace(vals[i]) == "" {
f.err = fld.label + " is required"
f.cursor = i
f.focusOnly(i)
return nil
}
}
f.busy = true
f.err = ""
return f.submit(vals)
}
func (f *form) advance(delta int) { func (f *form) advance(delta int) {
n := len(f.fields) n := len(f.fields)
if n == 0 { if n == 0 {
@@ -230,9 +332,9 @@ func (f *form) advance(delta int) {
func (f *form) focusOnly(i int) { func (f *form) focusOnly(i int) {
for j := range f.fields { for j := range f.fields {
if j == i { if j == i {
f.fields[j].input.Focus() f.fields[j].focus()
} else { } else {
f.fields[j].input.Blur() f.fields[j].blur()
} }
} }
} }
@@ -294,7 +396,7 @@ func newAddDiscordForm() *form {
textField("Name", "human-friendly identifier", true), textField("Name", "human-friendly identifier", true),
textField("Webhook URL", "https://discord.com/api/webhooks/...", true), textField("Webhook URL", "https://discord.com/api/webhooks/...", true),
textField("Default", "yes/no — attach to every check automatically", false), textField("Default", "yes/no — attach to every check automatically", false),
textField("Body template", alerts.TemplateVarsHint(), false), textAreaField("Body template", alerts.TemplateVarsHint(), false),
} }
return newForm("Add Discord alert", fields, func(vals []string) tea.Cmd { return newForm("Add Discord alert", fields, func(vals []string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
@@ -326,7 +428,7 @@ func newAddSMTPForm() *form {
textField("StartTLS", "yes/no — default yes", false), textField("StartTLS", "yes/no — default yes", false),
textField("Default", "yes/no — attach to every check", false), textField("Default", "yes/no — attach to every check", false),
textField("Subject template", alerts.TemplateVarsHint(), false), textField("Subject template", alerts.TemplateVarsHint(), false),
textField("Body template", alerts.TemplateVarsHint(), false), textAreaField("Body template", alerts.TemplateVarsHint(), false),
} }
return newForm("Add SMTP alert", fields, func(vals []string) tea.Cmd { return newForm("Add SMTP alert", fields, func(vals []string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
@@ -467,7 +569,7 @@ func newEditDiscordForm(existing config.Alert) *form {
textFieldWithValue("Name", "human-friendly identifier", existing.Name, true), textFieldWithValue("Name", "human-friendly identifier", existing.Name, true),
textFieldWithValue("Webhook URL", "https://discord.com/api/webhooks/...", existing.DiscordWebhook, true), textFieldWithValue("Webhook URL", "https://discord.com/api/webhooks/...", existing.DiscordWebhook, true),
textFieldWithValue("Default", "yes/no — attach to every check automatically", boolStr(existing.Default), false), textFieldWithValue("Default", "yes/no — attach to every check automatically", boolStr(existing.Default), false),
textFieldWithValue("Body template", alerts.TemplateVarsHint(), existing.BodyTemplate, false), textAreaFieldWithValue("Body template", alerts.TemplateVarsHint(), existing.BodyTemplate, false),
} }
id := existing.ID id := existing.ID
subject := existing.SubjectTemplate subject := existing.SubjectTemplate
@@ -506,7 +608,7 @@ func newEditSMTPForm(existing config.Alert) *form {
textFieldWithValue("StartTLS", "yes/no — default yes", boolStr(existing.SMTPStartTLS), false), textFieldWithValue("StartTLS", "yes/no — default yes", boolStr(existing.SMTPStartTLS), false),
textFieldWithValue("Default", "yes/no — attach to every check", boolStr(existing.Default), false), textFieldWithValue("Default", "yes/no — attach to every check", boolStr(existing.Default), false),
textFieldWithValue("Subject template", alerts.TemplateVarsHint(), existing.SubjectTemplate, false), textFieldWithValue("Subject template", alerts.TemplateVarsHint(), existing.SubjectTemplate, false),
textFieldWithValue("Body template", alerts.TemplateVarsHint(), existing.BodyTemplate, false), textAreaFieldWithValue("Body template", alerts.TemplateVarsHint(), existing.BodyTemplate, false),
} }
id := existing.ID id := existing.ID
return newForm("Edit SMTP alert", fields, func(vals []string) tea.Cmd { return newForm("Edit SMTP alert", fields, func(vals []string) tea.Cmd {