feat(tui): add the lazygit-style three-pane interface
Topics, messages, and message detail in three panes, with filtering, publishing, adding topics, a raw JSON view, history restore, and open-link support.
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
"lazyntfy/internal/ntfy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type publishDone struct{ err error }
|
||||||
|
type openDone struct{ err error }
|
||||||
|
|
||||||
|
func publishCmd(client *ntfy.Client, sub config.Sub, req ntfy.PublishRequest) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return publishDone{err: client.Publish(ctx, sub.Origin, sub.Name, sub.Auth, req)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openCmd(raw string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return openDone{err: openURL(raw)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openURL(raw string) error {
|
||||||
|
if err := validateURL(raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return exec.Command("xdg-open", raw).Start()
|
||||||
|
}
|
||||||
@@ -0,0 +1,844 @@
|
|||||||
|
// Package tui is the lazyntfy terminal UI.
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/textinput"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"lazyntfy/internal/alert"
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
"lazyntfy/internal/history"
|
||||||
|
"lazyntfy/internal/ntfy"
|
||||||
|
"lazyntfy/internal/store"
|
||||||
|
"lazyntfy/internal/textutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type focus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
focusTopics focus = iota
|
||||||
|
focusMessages
|
||||||
|
focusDetail
|
||||||
|
)
|
||||||
|
|
||||||
|
type mode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
modeNormal mode = iota
|
||||||
|
modeFilter
|
||||||
|
modePublish
|
||||||
|
modeAdd
|
||||||
|
modeHelp
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model is the Bubble Tea root model.
|
||||||
|
type Model struct {
|
||||||
|
app config.App
|
||||||
|
subs []config.Sub
|
||||||
|
notices <-chan ntfy.Notice
|
||||||
|
client *ntfy.Client
|
||||||
|
mgr *ntfy.Manager
|
||||||
|
hist *history.DB
|
||||||
|
store *store.Store
|
||||||
|
selected map[string]string
|
||||||
|
up map[string]bool
|
||||||
|
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
focus focus
|
||||||
|
mode mode
|
||||||
|
|
||||||
|
topicIdx int
|
||||||
|
detailTop int
|
||||||
|
filter string
|
||||||
|
raw bool
|
||||||
|
status string
|
||||||
|
restored int
|
||||||
|
savedTopic string
|
||||||
|
filterInput textinput.Model
|
||||||
|
addInput textinput.Model
|
||||||
|
form [4]textinput.Model
|
||||||
|
formIdx int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a model. notices, client, mgr, and hist may be nil in tests.
|
||||||
|
func New(app config.App, notices <-chan ntfy.Notice, client *ntfy.Client, mgr *ntfy.Manager, hist *history.DB) Model {
|
||||||
|
status := "connecting"
|
||||||
|
focusPane := focusMessages
|
||||||
|
if app.StartupStatus != "" {
|
||||||
|
status = app.StartupStatus
|
||||||
|
}
|
||||||
|
if len(app.Subs) == 0 {
|
||||||
|
focusPane = focusTopics
|
||||||
|
if app.StartupStatus == "" {
|
||||||
|
status = "no topics yet — press a to add one"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m := Model{
|
||||||
|
app: app,
|
||||||
|
subs: append([]config.Sub(nil), app.Subs...),
|
||||||
|
notices: notices,
|
||||||
|
client: client,
|
||||||
|
mgr: mgr,
|
||||||
|
hist: hist,
|
||||||
|
store: store.New(),
|
||||||
|
selected: map[string]string{},
|
||||||
|
up: map[string]bool{},
|
||||||
|
status: status,
|
||||||
|
focus: focusPane,
|
||||||
|
}
|
||||||
|
m.filterInput = newField("", "filter")
|
||||||
|
m.filterInput.CharLimit = 200
|
||||||
|
m.addInput = newField("", "topic or host/topic")
|
||||||
|
m.addInput.CharLimit = 200
|
||||||
|
placeholders := [4]string{"optional title", "3", "comma separated", "message"}
|
||||||
|
for i, placeholder := range placeholders {
|
||||||
|
m.form[i] = newField("", placeholder)
|
||||||
|
m.form[i].CharLimit = 256
|
||||||
|
}
|
||||||
|
m.form[3].CharLimit = 4000
|
||||||
|
m.resizeInputs()
|
||||||
|
if hist != nil {
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
msgs, err := hist.Messages(sub.Name, 0)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, msg := range msgs {
|
||||||
|
m.store.Put(msg)
|
||||||
|
m.restored++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name, ok := hist.State("topic"); ok && name != "" {
|
||||||
|
m.savedTopic = name
|
||||||
|
m = m.focusTopic(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func newField(prompt, placeholder string) textinput.Model {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Prompt = prompt
|
||||||
|
ti.Placeholder = placeholder
|
||||||
|
ti.SetWidth(48)
|
||||||
|
return ti
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init starts the subscription listener.
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
if m.notices == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return waitNotice(m.notices)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitNotice(ch <-chan ntfy.Notice) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
n, ok := <-ch
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles input, stream notices, and publish results.
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
m.resizeInputs()
|
||||||
|
return m, nil
|
||||||
|
case ntfy.Notice:
|
||||||
|
m = m.apply(msg)
|
||||||
|
return m, waitNotice(m.notices)
|
||||||
|
case publishDone:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.status = msg.err.Error()
|
||||||
|
} else {
|
||||||
|
m.status = "published"
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case openDone:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.status = msg.err.Error()
|
||||||
|
} else {
|
||||||
|
m.status = "opened"
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
if keyString(msg) == "ctrl+c" {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
switch m.mode {
|
||||||
|
case modeFilter:
|
||||||
|
return m.updateFilter(msg)
|
||||||
|
case modePublish:
|
||||||
|
return m.updatePublish(msg)
|
||||||
|
case modeAdd:
|
||||||
|
return m.updateAdd(msg)
|
||||||
|
case modeHelp:
|
||||||
|
m.mode = modeNormal
|
||||||
|
return m, nil
|
||||||
|
default:
|
||||||
|
return m.updateNormal(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateNormal(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch keyString(msg) {
|
||||||
|
case "q":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "j", "down":
|
||||||
|
return m.move(1), nil
|
||||||
|
case "k", "up":
|
||||||
|
return m.move(-1), nil
|
||||||
|
case "ctrl+d", "pgdown":
|
||||||
|
return m.move(m.page()), nil
|
||||||
|
case "ctrl+u", "pgup":
|
||||||
|
return m.move(-m.page()), nil
|
||||||
|
case "g", "home":
|
||||||
|
return m.moveEdge(true), nil
|
||||||
|
case "G", "end", "shift+g":
|
||||||
|
return m.moveEdge(false), nil
|
||||||
|
case "tab":
|
||||||
|
m.focus = (m.focus + 1) % 3
|
||||||
|
return m, nil
|
||||||
|
case "shift+tab":
|
||||||
|
m.focus = (m.focus + 2) % 3
|
||||||
|
return m, nil
|
||||||
|
case "1":
|
||||||
|
m.focus = focusTopics
|
||||||
|
return m, nil
|
||||||
|
case "2":
|
||||||
|
m.focus = focusMessages
|
||||||
|
return m, nil
|
||||||
|
case "3":
|
||||||
|
m.focus = focusDetail
|
||||||
|
return m, nil
|
||||||
|
case "enter":
|
||||||
|
switch m.focus {
|
||||||
|
case focusTopics:
|
||||||
|
m.focus = focusMessages
|
||||||
|
case focusMessages:
|
||||||
|
m.focus = focusDetail
|
||||||
|
default:
|
||||||
|
m.focus = focusMessages
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "/":
|
||||||
|
m.mode = modeFilter
|
||||||
|
m.filterInput.SetValue(m.filter)
|
||||||
|
return m, m.filterInput.Focus()
|
||||||
|
case "esc":
|
||||||
|
if m.filter != "" {
|
||||||
|
m.filter = ""
|
||||||
|
m.filterInput.SetValue("")
|
||||||
|
return m.ensureSelection(), nil
|
||||||
|
}
|
||||||
|
if m.focus == focusDetail {
|
||||||
|
m.focus = focusMessages
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "r":
|
||||||
|
if m.mgr != nil {
|
||||||
|
m.mgr.Reload()
|
||||||
|
}
|
||||||
|
m.status = "reconnecting"
|
||||||
|
return m, nil
|
||||||
|
case "p":
|
||||||
|
return m.beginPublish()
|
||||||
|
case "a":
|
||||||
|
return m.beginAdd()
|
||||||
|
case "v":
|
||||||
|
m.raw = !m.raw
|
||||||
|
m.detailTop = 0
|
||||||
|
return m, nil
|
||||||
|
case "?":
|
||||||
|
m.mode = modeHelp
|
||||||
|
return m, nil
|
||||||
|
case "o":
|
||||||
|
return m.openClick()
|
||||||
|
default:
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateFilter(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch keyString(msg) {
|
||||||
|
case "enter":
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.filter = m.filterInput.Value()
|
||||||
|
m.filterInput.Blur()
|
||||||
|
return m.ensureSelection(), nil
|
||||||
|
case "esc":
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.filter = ""
|
||||||
|
m.filterInput.SetValue("")
|
||||||
|
m.filterInput.Blur()
|
||||||
|
return m.ensureSelection(), nil
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.filterInput, cmd = m.filterInput.Update(msg)
|
||||||
|
m.filter = m.filterInput.Value()
|
||||||
|
return m.ensureSelection(), cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateAdd(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch keyString(msg) {
|
||||||
|
case "enter":
|
||||||
|
return m.submitAdd(), nil
|
||||||
|
case "esc":
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.addInput.Blur()
|
||||||
|
return m, nil
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.addInput, cmd = m.addInput.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updatePublish(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch keyString(msg) {
|
||||||
|
case "esc":
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.blurForm()
|
||||||
|
return m, nil
|
||||||
|
case "tab", "down":
|
||||||
|
return m.formStep(1)
|
||||||
|
case "shift+tab", "up":
|
||||||
|
return m.formStep(-1)
|
||||||
|
case "enter":
|
||||||
|
if m.formIdx < len(m.form)-1 {
|
||||||
|
return m.formStep(1)
|
||||||
|
}
|
||||||
|
return m.submitPublish()
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.form[m.formIdx], cmd = m.form[m.formIdx].Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) formStep(delta int) (tea.Model, tea.Cmd) {
|
||||||
|
m.form[m.formIdx].Blur()
|
||||||
|
m.formIdx += delta
|
||||||
|
if m.formIdx < 0 {
|
||||||
|
m.formIdx = len(m.form) - 1
|
||||||
|
}
|
||||||
|
if m.formIdx >= len(m.form) {
|
||||||
|
m.formIdx = 0
|
||||||
|
}
|
||||||
|
return m, m.form[m.formIdx].Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) beginPublish() (Model, tea.Cmd) {
|
||||||
|
if _, ok := m.currentSub(); !ok {
|
||||||
|
m.status = "no topic to publish to"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.client == nil {
|
||||||
|
m.status = "publish unavailable"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.mode = modePublish
|
||||||
|
m.formIdx = 0
|
||||||
|
for i := range m.form {
|
||||||
|
m.form[i].SetValue("")
|
||||||
|
m.form[i].Blur()
|
||||||
|
}
|
||||||
|
return m, m.form[0].Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) submitPublish() (Model, tea.Cmd) {
|
||||||
|
sub, ok := m.currentSub()
|
||||||
|
if !ok {
|
||||||
|
m.status = "no topic to publish to"
|
||||||
|
m.mode = modeNormal
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
priority, err := ntfy.ParsePriority(m.form[1].Value())
|
||||||
|
if err != nil {
|
||||||
|
m.status = err.Error()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
message := strings.TrimSpace(m.form[3].Value())
|
||||||
|
if message == "" {
|
||||||
|
m.status = "message is empty"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
req := ntfy.PublishRequest{
|
||||||
|
Title: strings.TrimSpace(m.form[0].Value()),
|
||||||
|
Priority: priority,
|
||||||
|
Tags: ntfy.ParseTags(m.form[2].Value()),
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.blurForm()
|
||||||
|
m.status = "publishing"
|
||||||
|
return m, publishCmd(m.client, sub, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) beginAdd() (Model, tea.Cmd) {
|
||||||
|
m.mode = modeAdd
|
||||||
|
m.addInput.SetValue("")
|
||||||
|
return m, m.addInput.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) submitAdd() Model {
|
||||||
|
m.addInput.Blur()
|
||||||
|
m.mode = modeNormal
|
||||||
|
origin, name, err := config.Resolve(m.addInput.Value(), m.app.DefaultHost)
|
||||||
|
if err != nil {
|
||||||
|
m.status = err.Error()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
if sub.Origin == origin && sub.Name == name {
|
||||||
|
m.status = name + " already open"
|
||||||
|
return m.focusTopic(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sub := config.Sub{Origin: origin, Name: name, Auth: m.app.Auth}
|
||||||
|
m.subs = append(m.subs, sub)
|
||||||
|
if m.mgr != nil {
|
||||||
|
m.mgr.Add(sub)
|
||||||
|
}
|
||||||
|
if m.hist != nil {
|
||||||
|
_ = m.hist.SaveTopic(config.Sub{Origin: origin, Name: name})
|
||||||
|
}
|
||||||
|
m.status = "subscribed to " + name
|
||||||
|
return m.focusTopic(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) focusTopic(name string) Model {
|
||||||
|
names := m.topicNames()
|
||||||
|
for i, n := range names {
|
||||||
|
if n == name {
|
||||||
|
m.topicIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.detailTop = 0
|
||||||
|
return m.ensureSelection().persistTopic()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) blurForm() {
|
||||||
|
for i := range m.form {
|
||||||
|
m.form[i].Blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) openClick() (Model, tea.Cmd) {
|
||||||
|
msg, ok := m.selectedMessage()
|
||||||
|
if !ok || msg.Click == "" {
|
||||||
|
m.status = "no click URL"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, openCmd(msg.Click)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) apply(n ntfy.Notice) Model {
|
||||||
|
if n.UpSet {
|
||||||
|
for _, topic := range n.Topics {
|
||||||
|
for _, part := range strings.Split(topic, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
m.up[part] = n.Up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n.Status != "" {
|
||||||
|
m.status = n.Status
|
||||||
|
}
|
||||||
|
if n.Event != nil {
|
||||||
|
switch n.Event.Event {
|
||||||
|
case "message":
|
||||||
|
msg := toMessage(*n.Event)
|
||||||
|
m.store.Put(msg)
|
||||||
|
m.remember(msg)
|
||||||
|
case "message_delete":
|
||||||
|
m.store.Delete(n.Event.ID)
|
||||||
|
if m.hist != nil {
|
||||||
|
_ = m.hist.DeleteMessage(n.Event.ID)
|
||||||
|
}
|
||||||
|
case "message_clear":
|
||||||
|
m.store.Clear(n.Event.Topic)
|
||||||
|
if m.hist != nil {
|
||||||
|
_ = m.hist.ClearTopic(n.Event.Topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.ensureSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMessage(ev ntfy.Event) store.Message {
|
||||||
|
msg := store.Message{
|
||||||
|
ID: ev.ID,
|
||||||
|
Time: ev.Time,
|
||||||
|
Expires: ev.Expires,
|
||||||
|
Topic: ev.Topic,
|
||||||
|
Title: ev.Title,
|
||||||
|
Body: ev.Message,
|
||||||
|
Tags: ev.Tags,
|
||||||
|
Priority: ev.Priority,
|
||||||
|
Click: ev.Click,
|
||||||
|
}
|
||||||
|
if ev.Attachment != nil {
|
||||||
|
msg.Attachment = &store.Attachment{
|
||||||
|
Name: ev.Attachment.Name,
|
||||||
|
URL: ev.Attachment.URL,
|
||||||
|
Type: ev.Attachment.Type,
|
||||||
|
Size: ev.Attachment.Size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ev.Actions) > 0 {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := json.Indent(&buf, ev.Actions, "", " "); err == nil {
|
||||||
|
msg.Actions = buf.String()
|
||||||
|
} else {
|
||||||
|
msg.Actions = string(ev.Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) originFor(topic string) string {
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
if sub.Name == topic {
|
||||||
|
return sub.Origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) remember(msg store.Message) {
|
||||||
|
if m.hist == nil || msg.ID == "" || msg.Topic == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
display := ""
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
if sub.Name == msg.Topic {
|
||||||
|
display = sub.Display
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = m.hist.SaveTopic(config.Sub{Origin: m.originFor(msg.Topic), Name: msg.Topic, Display: display})
|
||||||
|
_ = m.hist.SaveMessage(m.originFor(msg.Topic), msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) persistTopic() Model {
|
||||||
|
if m.hist == nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
name := m.currentName()
|
||||||
|
if name == "" || name == m.savedTopic {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
if err := m.hist.SetState("topic", name); err == nil {
|
||||||
|
m.savedTopic = name
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) move(delta int) Model {
|
||||||
|
switch m.focus {
|
||||||
|
case focusTopics:
|
||||||
|
m.topicIdx += delta
|
||||||
|
m.clampTopic()
|
||||||
|
m.detailTop = 0
|
||||||
|
return m.ensureSelection().persistTopic()
|
||||||
|
case focusDetail:
|
||||||
|
m.detailTop += delta
|
||||||
|
return m.clampDetail()
|
||||||
|
default:
|
||||||
|
msgs := m.filtered()
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
name := m.currentName()
|
||||||
|
idx := indexOf(msgs, m.selected[name]) + delta
|
||||||
|
if idx < 0 {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
if idx >= len(msgs) {
|
||||||
|
idx = len(msgs) - 1
|
||||||
|
}
|
||||||
|
m.selected[name] = msgs[idx].ID
|
||||||
|
m.detailTop = 0
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) moveEdge(top bool) Model {
|
||||||
|
switch m.focus {
|
||||||
|
case focusTopics:
|
||||||
|
if top {
|
||||||
|
m.topicIdx = 0
|
||||||
|
} else {
|
||||||
|
m.topicIdx = len(m.topicNames()) - 1
|
||||||
|
}
|
||||||
|
m.clampTopic()
|
||||||
|
m.detailTop = 0
|
||||||
|
return m.ensureSelection().persistTopic()
|
||||||
|
case focusDetail:
|
||||||
|
if top {
|
||||||
|
m.detailTop = 0
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
m.detailTop = 1 << 30
|
||||||
|
return m.clampDetail()
|
||||||
|
default:
|
||||||
|
msgs := m.filtered()
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
idx := 0
|
||||||
|
if !top {
|
||||||
|
idx = len(msgs) - 1
|
||||||
|
}
|
||||||
|
m.selected[m.currentName()] = msgs[idx].ID
|
||||||
|
m.detailTop = 0
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) page() int {
|
||||||
|
n := m.bodyRows() - 1
|
||||||
|
if n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) resizeInputs() {
|
||||||
|
w := m.width / 2
|
||||||
|
if w < 20 {
|
||||||
|
w = 20
|
||||||
|
}
|
||||||
|
if w > 80 {
|
||||||
|
w = 80
|
||||||
|
}
|
||||||
|
m.filterInput.SetWidth(w)
|
||||||
|
m.addInput.SetWidth(w)
|
||||||
|
for i := range m.form {
|
||||||
|
m.form[i].SetWidth(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyString(msg tea.KeyPressMsg) string {
|
||||||
|
if s := msg.String(); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return msg.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(unix int64) string {
|
||||||
|
if unix == 0 {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
return time.Unix(unix, 0).Local().Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatShort(unix int64) string {
|
||||||
|
if unix == 0 {
|
||||||
|
return " ? "
|
||||||
|
}
|
||||||
|
return time.Unix(unix, 0).Local().Format("01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
func priLabel(p int) string {
|
||||||
|
switch p {
|
||||||
|
case 1:
|
||||||
|
return "min"
|
||||||
|
case 2:
|
||||||
|
return "low"
|
||||||
|
case 3:
|
||||||
|
return "def"
|
||||||
|
case 4:
|
||||||
|
return "high"
|
||||||
|
case 5:
|
||||||
|
return "max"
|
||||||
|
default:
|
||||||
|
return "·"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(msgs []store.Message, id string) int {
|
||||||
|
for i, msg := range msgs {
|
||||||
|
if msg.ID == id {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) topicNames() []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var names []string
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
if sub.Name == "" || seen[sub.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[sub.Name] = true
|
||||||
|
names = append(names, sub.Name)
|
||||||
|
}
|
||||||
|
for _, topic := range m.store.Topics() {
|
||||||
|
if topic == "" || seen[topic] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[topic] = true
|
||||||
|
names = append(names, topic)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) clampTopic() Model {
|
||||||
|
n := len(m.topicNames())
|
||||||
|
if n == 0 {
|
||||||
|
m.topicIdx = 0
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
if m.topicIdx < 0 {
|
||||||
|
m.topicIdx = 0
|
||||||
|
}
|
||||||
|
if m.topicIdx >= n {
|
||||||
|
m.topicIdx = n - 1
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) currentName() string {
|
||||||
|
names := m.topicNames()
|
||||||
|
if len(names) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
m = m.clampTopic()
|
||||||
|
return names[m.topicIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) currentSub() (config.Sub, bool) {
|
||||||
|
name := m.currentName()
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
if sub.Name == name {
|
||||||
|
return sub, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config.Sub{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) filtered() []store.Message {
|
||||||
|
var out []store.Message
|
||||||
|
for _, msg := range m.store.List(m.currentName()) {
|
||||||
|
title, _ := alert.TitleSeverity(msg.Title, msg.Body)
|
||||||
|
if store.Match(msg, title, m.filter) {
|
||||||
|
out = append(out, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) ensureSelection() Model {
|
||||||
|
msgs := m.filtered()
|
||||||
|
name := m.currentName()
|
||||||
|
if name == "" || len(msgs) == 0 {
|
||||||
|
delete(m.selected, name)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
id := m.selected[name]
|
||||||
|
for _, msg := range msgs {
|
||||||
|
if msg.ID == id {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.selected[name] = msgs[0].ID
|
||||||
|
m.detailTop = 0
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) selectedMessage() (store.Message, bool) {
|
||||||
|
id := m.selected[m.currentName()]
|
||||||
|
if id == "" {
|
||||||
|
return store.Message{}, false
|
||||||
|
}
|
||||||
|
for _, msg := range m.filtered() {
|
||||||
|
if msg.ID == id {
|
||||||
|
return msg, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.Message{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) bodyRows() int {
|
||||||
|
n := m.height - 4
|
||||||
|
if n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) topicLabel(name string) string {
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
if sub.Name == name {
|
||||||
|
return sub.Label()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) clampDetail() Model {
|
||||||
|
_, _, _, _, _, dw := m.layout()
|
||||||
|
width := dw - 1
|
||||||
|
if width < 8 {
|
||||||
|
width = 8
|
||||||
|
}
|
||||||
|
visible := m.bodyRows()
|
||||||
|
if visible < 1 {
|
||||||
|
visible = 1
|
||||||
|
}
|
||||||
|
maxTop := len(m.detailBlocks(width)) - visible
|
||||||
|
if maxTop < 0 {
|
||||||
|
maxTop = 0
|
||||||
|
}
|
||||||
|
if m.detailTop < 0 {
|
||||||
|
m.detailTop = 0
|
||||||
|
}
|
||||||
|
if m.detailTop > maxTop {
|
||||||
|
m.detailTop = maxTop
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatListTime(unix int64) string {
|
||||||
|
if unix == 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
t := time.Unix(unix, 0).Local()
|
||||||
|
now := time.Now()
|
||||||
|
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
|
||||||
|
return t.Format("15:04")
|
||||||
|
}
|
||||||
|
if t.Year() == now.Year() {
|
||||||
|
return t.Format("Jan 02")
|
||||||
|
}
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapText(s string, width int) []string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return []string{""}
|
||||||
|
}
|
||||||
|
return textutil.Wrap(s, width)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
"lazyntfy/internal/history"
|
||||||
|
"lazyntfy/internal/ntfy"
|
||||||
|
"lazyntfy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sample() Model {
|
||||||
|
m := New(config.App{
|
||||||
|
DefaultHost: "https://ntfy.example",
|
||||||
|
Auth: config.Auth{Token: "tk_test"},
|
||||||
|
Subs: []config.Sub{{Origin: "https://ntfy.example", Name: "alerts", Auth: config.Auth{Token: "tk_test"}}},
|
||||||
|
}, nil, ntfy.NewClient(), nil, nil)
|
||||||
|
m.width = 120
|
||||||
|
m.height = 30
|
||||||
|
m.store.Put(store.Message{ID: "new", Time: 200, Topic: "alerts", Title: "DiskFull", Body: `{"status":"firing","commonLabels":{"alertname":"DiskFull","severity":"critical"}}`})
|
||||||
|
m.store.Put(store.Message{ID: "old", Time: 100, Topic: "alerts", Body: "backup finished"})
|
||||||
|
m = m.ensureSelection()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewShowsNewestAlert(t *testing.T) {
|
||||||
|
view := sample().render()
|
||||||
|
if !strings.Contains(view, "lazyntfy") || !strings.Contains(view, "DiskFull") || !strings.Contains(view, "firing") {
|
||||||
|
t.Fatalf("%s", view)
|
||||||
|
}
|
||||||
|
lines := strings.Split(view, "\n")
|
||||||
|
if len(lines) != 30 {
|
||||||
|
t.Fatalf("height %d", len(lines))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveKeepsSelectionByID(t *testing.T) {
|
||||||
|
m := sample()
|
||||||
|
if m.selected["alerts"] != "new" {
|
||||||
|
t.Fatalf("selected %s", m.selected["alerts"])
|
||||||
|
}
|
||||||
|
m = m.move(1)
|
||||||
|
if m.selected["alerts"] != "old" {
|
||||||
|
t.Fatalf("after down %s", m.selected["alerts"])
|
||||||
|
}
|
||||||
|
next, cmd := m.Update(tea.KeyPressMsg{Text: "q", Code: 'q'})
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected quit")
|
||||||
|
}
|
||||||
|
if next.(Model).selected["alerts"] != "old" {
|
||||||
|
t.Fatal("quit changed selection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterAndDelete(t *testing.T) {
|
||||||
|
m := sample()
|
||||||
|
m.filter = "backup"
|
||||||
|
m = m.ensureSelection()
|
||||||
|
if m.selected["alerts"] != "old" {
|
||||||
|
t.Fatalf("filter selected %s", m.selected["alerts"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.render(), "backup finished") {
|
||||||
|
t.Fatal(m.render())
|
||||||
|
}
|
||||||
|
m = m.apply(ntfy.Notice{Event: &ntfy.Event{ID: "old", Event: "message_delete", Topic: "alerts"}})
|
||||||
|
if len(m.store.List("alerts")) != 1 {
|
||||||
|
t.Fatal("delete")
|
||||||
|
}
|
||||||
|
m = m.apply(ntfy.Notice{Event: &ntfy.Event{Event: "message_clear", Topic: "alerts"}})
|
||||||
|
if len(m.store.List("alerts")) != 0 {
|
||||||
|
t.Fatal("clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishRequiresMessage(t *testing.T) {
|
||||||
|
m := sample()
|
||||||
|
m.mode = modePublish
|
||||||
|
m.formIdx = 3
|
||||||
|
got, _ := m.updatePublish(tea.KeyPressMsg{Text: "enter", Code: tea.KeyEnter})
|
||||||
|
if got.(Model).status != "message is empty" {
|
||||||
|
t.Fatalf("status %q", got.(Model).status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddTopicUsesDefaultHost(t *testing.T) {
|
||||||
|
m := sample()
|
||||||
|
m.addInput.SetValue("backups")
|
||||||
|
m = m.submitAdd()
|
||||||
|
if len(m.subs) != 2 || m.subs[1].Name != "backups" || m.subs[1].Origin != "https://ntfy.example" {
|
||||||
|
t.Fatalf("%+v", m.subs)
|
||||||
|
}
|
||||||
|
if m.subs[1].Auth.Token != "tk_test" {
|
||||||
|
t.Fatal("auth not copied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectsNonHTTPClick(t *testing.T) {
|
||||||
|
if err := validateURL("file:///etc/passwd"); err == nil {
|
||||||
|
t.Fatal("expected reject")
|
||||||
|
}
|
||||||
|
if err := validateURL("https://example.com/a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoresMessagesAndTopicFromHistory(t *testing.T) {
|
||||||
|
db, err := history.Open(filepath.Join(t.TempDir(), "history.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := db.SaveMessage("https://ntfy.example", store.Message{
|
||||||
|
ID: "h1", Topic: "alerts", Time: 50, Title: "from disk",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.SetState("topic", "alerts"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := New(config.App{
|
||||||
|
DefaultHost: "https://ntfy.example",
|
||||||
|
Subs: []config.Sub{{Origin: "https://ntfy.example", Name: "alerts"}},
|
||||||
|
}, nil, ntfy.NewClient(), nil, db)
|
||||||
|
if m.restored != 1 || len(m.store.List("alerts")) != 1 {
|
||||||
|
t.Fatalf("restored %d messages %d", m.restored, len(m.store.List("alerts")))
|
||||||
|
}
|
||||||
|
if m.savedTopic != "alerts" || m.currentName() != "alerts" {
|
||||||
|
t.Fatalf("saved %q current %q", m.savedTopic, m.currentName())
|
||||||
|
}
|
||||||
|
m.width, m.height = 120, 30
|
||||||
|
if !strings.Contains(m.render(), "from disk") {
|
||||||
|
t.Fatalf("restored message missing from view:\n%s", m.render())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateURL(raw string) error {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
|
||||||
|
return fmt.Errorf("only http(s) links can be opened")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"lazyntfy/internal/alert"
|
||||||
|
"lazyntfy/internal/store"
|
||||||
|
"lazyntfy/internal/textutil"
|
||||||
|
"lazyntfy/internal/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
text string
|
||||||
|
style lipgloss.Style
|
||||||
|
raw bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// View draws the alternate-screen interface.
|
||||||
|
func (m Model) View() tea.View {
|
||||||
|
v := tea.NewView(m.render())
|
||||||
|
v.AltScreen = true
|
||||||
|
v.WindowTitle = "lazyntfy"
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) render() string {
|
||||||
|
if m.width < 40 || m.height < 8 {
|
||||||
|
return "terminal too small\n"
|
||||||
|
}
|
||||||
|
switch m.mode {
|
||||||
|
case modeHelp:
|
||||||
|
return m.fit(m.modal("help", m.helpRows()))
|
||||||
|
case modePublish:
|
||||||
|
return m.fit(m.modal("publish", m.publishRows()))
|
||||||
|
case modeAdd:
|
||||||
|
return m.fit(m.modal("add topic", m.addRows()))
|
||||||
|
default:
|
||||||
|
return m.fit(m.renderPanes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) fit(body string) string {
|
||||||
|
lines := strings.Split(strings.TrimRight(body, "\n"), "\n")
|
||||||
|
for len(lines) < m.height {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
if len(lines) > m.height {
|
||||||
|
if m.height >= 2 {
|
||||||
|
tail := lines[len(lines)-1]
|
||||||
|
lines = append(lines[:m.height-1], tail)
|
||||||
|
} else {
|
||||||
|
lines = lines[:m.height]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderPanes() string {
|
||||||
|
panelH := m.height - 2
|
||||||
|
showT, showM, showD, tw, mw, dw := m.layout()
|
||||||
|
var parts []string
|
||||||
|
if showT {
|
||||||
|
parts = append(parts, box("topics", m.focus == focusTopics, tw, panelH, m.topicRows(tw-2)))
|
||||||
|
}
|
||||||
|
if showM {
|
||||||
|
parts = append(parts, box("messages", m.focus == focusMessages, mw, panelH, m.messageRows(mw-2)))
|
||||||
|
}
|
||||||
|
if showD {
|
||||||
|
parts = append(parts, box(m.detailTitle(), m.focus == focusDetail, dw, panelH, m.detailRows(dw-2)))
|
||||||
|
}
|
||||||
|
gapped := make([]string, 0, len(parts)*2-1)
|
||||||
|
for i, part := range parts {
|
||||||
|
if i > 0 {
|
||||||
|
gapped = append(gapped, gapColumn(panelH))
|
||||||
|
}
|
||||||
|
gapped = append(gapped, part)
|
||||||
|
}
|
||||||
|
mid := lipgloss.JoinHorizontal(lipgloss.Top, gapped...)
|
||||||
|
return m.headerLine() + "\n" + mid + "\n" + m.footerLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) headerLine() string {
|
||||||
|
left := badgeStyle.Render(" lazyntfy ")
|
||||||
|
if name := m.currentName(); name != "" {
|
||||||
|
left += " " + lipgloss.NewStyle().Bold(true).Render(m.topicLabel(name))
|
||||||
|
left += dimStyle.Render(fmt.Sprintf(" %d/%d", len(m.filtered()), len(m.store.List(name))))
|
||||||
|
if m.restored > 0 {
|
||||||
|
left += dimStyle.Render(fmt.Sprintf(" history %d", m.restored))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.filter != "" {
|
||||||
|
left += " " + filterStyle.Render(" /"+m.filter+" ")
|
||||||
|
}
|
||||||
|
right := m.statusStyle().Render(statusMark(m.status) + m.status + " ")
|
||||||
|
return spread(left, right, m.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusMark(status string) string {
|
||||||
|
s := strings.ToLower(status)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(s, "connected") || strings.HasPrefix(s, "published"):
|
||||||
|
return "● "
|
||||||
|
case strings.Contains(s, "disconnect") || strings.Contains(s, "retry") || strings.Contains(s, "not authorized"):
|
||||||
|
return "○ "
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) footerLine() string {
|
||||||
|
if m.mode == modeFilter {
|
||||||
|
return spread(filterStyle.Render(" filter ")+" "+m.filterInput.View(), dimStyle.Render("enter apply esc clear "), m.width)
|
||||||
|
}
|
||||||
|
var keys [][2]string
|
||||||
|
switch m.focus {
|
||||||
|
case focusTopics:
|
||||||
|
keys = [][2]string{{"j/k", "select"}, {"enter", "open"}, {"a", "add"}, {"1/2/3", "pane"}, {"?", "help"}, {"q", "quit"}}
|
||||||
|
case focusDetail:
|
||||||
|
keys = [][2]string{{"j/k", "scroll"}, {"v", "raw"}, {"o", "open link"}, {"esc", "back"}, {"?", "help"}, {"q", "quit"}}
|
||||||
|
default:
|
||||||
|
keys = [][2]string{{"j/k", "move"}, {"enter", "detail"}, {"/", "filter"}, {"p", "publish"}, {"r", "reload"}, {"q", "quit"}}
|
||||||
|
}
|
||||||
|
return spread(chips(keys), dimStyle.Render("? help "), m.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func chips(keys [][2]string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for i, key := range keys {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(" ")
|
||||||
|
}
|
||||||
|
b.WriteString(keyStyle.Render(" " + key[0] + " "))
|
||||||
|
b.WriteString(dimStyle.Render(" " + key[1]))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) layout() (showT, showM, showD bool, tw, mw, dw int) {
|
||||||
|
panels := 1
|
||||||
|
switch {
|
||||||
|
case m.width >= 100:
|
||||||
|
showT, showM, showD = true, true, true
|
||||||
|
panels = 3
|
||||||
|
tw = 28
|
||||||
|
case m.width >= 72:
|
||||||
|
showT = true
|
||||||
|
panels = 2
|
||||||
|
tw = 24
|
||||||
|
if m.focus == focusDetail {
|
||||||
|
showD = true
|
||||||
|
} else {
|
||||||
|
showM = true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
switch m.focus {
|
||||||
|
case focusTopics:
|
||||||
|
showT = true
|
||||||
|
case focusDetail:
|
||||||
|
showD = true
|
||||||
|
default:
|
||||||
|
showM = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest := m.width - (panels - 1)
|
||||||
|
if showT && panels > 1 {
|
||||||
|
if tw > rest-24 {
|
||||||
|
tw = 20
|
||||||
|
}
|
||||||
|
rest -= tw
|
||||||
|
} else if showT {
|
||||||
|
tw = rest
|
||||||
|
rest = 0
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case showM && showD:
|
||||||
|
mw = rest * 42 / 100
|
||||||
|
if mw < 24 {
|
||||||
|
mw = 24
|
||||||
|
}
|
||||||
|
dw = rest - mw
|
||||||
|
if dw < 16 {
|
||||||
|
dw = 16
|
||||||
|
mw = rest - dw
|
||||||
|
}
|
||||||
|
case showM:
|
||||||
|
mw = rest
|
||||||
|
case showD:
|
||||||
|
dw = rest
|
||||||
|
}
|
||||||
|
if tw < 0 {
|
||||||
|
tw = 0
|
||||||
|
}
|
||||||
|
if mw < 0 {
|
||||||
|
mw = 0
|
||||||
|
}
|
||||||
|
if dw < 0 {
|
||||||
|
dw = 0
|
||||||
|
}
|
||||||
|
return showT, showM, showD, tw, mw, dw
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) topicRows(width int) []row {
|
||||||
|
names := m.topicNames()
|
||||||
|
if len(names) == 0 {
|
||||||
|
return []row{
|
||||||
|
{text: "", style: lipgloss.NewStyle()},
|
||||||
|
{text: " No topics yet", style: dimStyle},
|
||||||
|
{text: "", style: lipgloss.NewStyle()},
|
||||||
|
{text: " Press a to add one", style: accentStyle},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows := make([]row, 0, len(names))
|
||||||
|
for i, name := range names {
|
||||||
|
mark := "○"
|
||||||
|
markStyle := dimStyle
|
||||||
|
if m.up[name] {
|
||||||
|
mark = "●"
|
||||||
|
markStyle = okStyle
|
||||||
|
}
|
||||||
|
count := ""
|
||||||
|
if n := len(m.store.List(name)); n > 0 {
|
||||||
|
count = fmt.Sprintf("%d", n)
|
||||||
|
}
|
||||||
|
label := m.topicLabel(name)
|
||||||
|
if i == m.topicIdx {
|
||||||
|
rows = append(rows, row{
|
||||||
|
text: splitLine(" "+mark+" "+label, count, width),
|
||||||
|
style: selectedStyle,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
left := " " + markStyle.Render(mark) + " " + theme.Text.Render(label)
|
||||||
|
right := dimStyle.Render(count)
|
||||||
|
rows = append(rows, row{text: splitStyled(left, right, width), raw: true})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) messageRows(width int) []row {
|
||||||
|
if m.currentName() == "" {
|
||||||
|
return []row{
|
||||||
|
{text: "", style: lipgloss.NewStyle()},
|
||||||
|
{text: " Topics from your account", style: lipgloss.NewStyle().Bold(true)},
|
||||||
|
{text: " show up in the list.", style: dimStyle},
|
||||||
|
{text: "", style: lipgloss.NewStyle()},
|
||||||
|
{text: " a add a topic", style: accentStyle},
|
||||||
|
{text: " ? keyboard help", style: dimStyle},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msgs := m.filtered()
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
hint := " Waiting for messages"
|
||||||
|
if m.filter != "" {
|
||||||
|
hint = " Nothing matches /" + m.filter
|
||||||
|
}
|
||||||
|
return []row{{text: hint, style: dimStyle}}
|
||||||
|
}
|
||||||
|
body := m.bodyRows()
|
||||||
|
selected := indexOf(msgs, m.selected[m.currentName()])
|
||||||
|
start := 0
|
||||||
|
if selected >= body {
|
||||||
|
start = selected - body + 1
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
for i := start; i < len(msgs) && len(rows) < body; i++ {
|
||||||
|
rows = append(rows, messageRow(msgs[i], width, msgs[i].ID == m.selected[m.currentName()]))
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageRow(msg store.Message, width int, selected bool) row {
|
||||||
|
title, sev := alert.TitleSeverity(msg.Title, msg.Body)
|
||||||
|
when := textutil.Pad(formatListTime(msg.Time), 6)
|
||||||
|
badge := sev
|
||||||
|
if badge == "" {
|
||||||
|
badge = priLabel(msg.Priority)
|
||||||
|
}
|
||||||
|
badge = textutil.Pad(textutil.Clip(badge, 8), 8)
|
||||||
|
room := width - 1 - 6 - 1 - 8 - 1
|
||||||
|
if room < 8 {
|
||||||
|
room = 8
|
||||||
|
}
|
||||||
|
title = textutil.Clip(title, room)
|
||||||
|
plain := " " + when + " " + badge + " " + title
|
||||||
|
if selected {
|
||||||
|
return row{text: plain, style: selectedStyle}
|
||||||
|
}
|
||||||
|
colored := " " + dimStyle.Render(when) + " " + toneStyle(alert.Tone(sev, msg.Priority)).Render(badge) + " " + theme.Text.Render(title)
|
||||||
|
return row{text: colored, raw: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) detailTitle() string {
|
||||||
|
n := len(m.detailBlocks(40))
|
||||||
|
visible := m.bodyRows()
|
||||||
|
maxTop := n - visible
|
||||||
|
if maxTop < 1 {
|
||||||
|
return "detail"
|
||||||
|
}
|
||||||
|
top := m.detailTop
|
||||||
|
if top > maxTop {
|
||||||
|
top = maxTop
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("detail %d/%d", top+1, maxTop+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) detailRows(width int) []row {
|
||||||
|
if width < 8 {
|
||||||
|
width = 8
|
||||||
|
}
|
||||||
|
blocks := m.detailBlocks(width)
|
||||||
|
visible := m.bodyRows()
|
||||||
|
maxTop := len(blocks) - visible
|
||||||
|
if maxTop < 0 {
|
||||||
|
maxTop = 0
|
||||||
|
}
|
||||||
|
top := m.detailTop
|
||||||
|
if top > maxTop {
|
||||||
|
top = maxTop
|
||||||
|
}
|
||||||
|
if len(blocks) == 0 {
|
||||||
|
return []row{{text: " Nothing selected", style: dimStyle}}
|
||||||
|
}
|
||||||
|
if top >= len(blocks) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
end := top + visible
|
||||||
|
if end > len(blocks) {
|
||||||
|
end = len(blocks)
|
||||||
|
}
|
||||||
|
return blocks[top:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) detailBlocks(width int) []row {
|
||||||
|
msg, ok := m.selectedMessage()
|
||||||
|
if !ok {
|
||||||
|
return []row{
|
||||||
|
{text: " Nothing selected", style: dimStyle},
|
||||||
|
{text: " Move through the list and press enter", style: dimStyle},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if width < 8 {
|
||||||
|
width = 8
|
||||||
|
}
|
||||||
|
title, sev := alert.TitleSeverity(msg.Title, msg.Body)
|
||||||
|
var rows []row
|
||||||
|
if sev != "" {
|
||||||
|
rows = append(rows, row{text: " " + strings.ToUpper(sev), style: toneStyle(alert.Tone(sev, msg.Priority)).Bold(true)})
|
||||||
|
}
|
||||||
|
rows = append(rows, row{text: " " + title, style: lipgloss.NewStyle().Bold(true)})
|
||||||
|
rows = append(rows, row{text: " " + formatTime(msg.Time) + " " + msg.ID, style: dimStyle})
|
||||||
|
var meta []string
|
||||||
|
if msg.Priority > 0 {
|
||||||
|
meta = append(meta, priLabel(msg.Priority))
|
||||||
|
}
|
||||||
|
if len(msg.Tags) > 0 {
|
||||||
|
meta = append(meta, strings.Join(msg.Tags, " "))
|
||||||
|
}
|
||||||
|
if len(meta) > 0 {
|
||||||
|
rows = append(rows, row{text: " " + strings.Join(meta, " · "), style: dimStyle})
|
||||||
|
}
|
||||||
|
rows = append(rows, row{text: "", style: lipgloss.NewStyle()})
|
||||||
|
for _, line := range wrapText(alert.BodyView(msg.Body, m.raw), width-2) {
|
||||||
|
rows = append(rows, row{text: " " + line, style: lipgloss.NewStyle()})
|
||||||
|
}
|
||||||
|
if msg.Click != "" {
|
||||||
|
rows = append(rows,
|
||||||
|
row{text: "", style: lipgloss.NewStyle()},
|
||||||
|
row{text: " link", style: dimStyle},
|
||||||
|
row{text: " " + msg.Click, style: accentStyle},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if msg.Attachment != nil {
|
||||||
|
rows = append(rows,
|
||||||
|
row{text: "", style: lipgloss.NewStyle()},
|
||||||
|
row{text: " attachment", style: dimStyle},
|
||||||
|
row{text: fmt.Sprintf(" %s (%d bytes)", msg.Attachment.Name, msg.Attachment.Size), style: lipgloss.NewStyle()},
|
||||||
|
)
|
||||||
|
if msg.Attachment.URL != "" {
|
||||||
|
rows = append(rows, row{text: " " + msg.Attachment.URL, style: dimStyle})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg.Expires > 0 {
|
||||||
|
rows = append(rows, row{text: " expires " + formatTime(msg.Expires), style: dimStyle})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) helpRows() []row {
|
||||||
|
pairs := [][2]string{
|
||||||
|
{"j / k", "move in the focused pane"},
|
||||||
|
{"g / G", "first and last"},
|
||||||
|
{"enter", "topics → messages → detail"},
|
||||||
|
{"esc", "back from detail, or clear filter"},
|
||||||
|
{"1 2 3", "jump to a pane"},
|
||||||
|
{"/", "filter title, body, tags, id"},
|
||||||
|
{"a", "add a topic and remember it"},
|
||||||
|
{"p", "publish to the focused topic"},
|
||||||
|
{"r", "reconnect"},
|
||||||
|
{"v", "toggle raw JSON"},
|
||||||
|
{"o", "open the click link"},
|
||||||
|
{"?", "close this help"},
|
||||||
|
{"q", "quit"},
|
||||||
|
}
|
||||||
|
rows := []row{{text: " Keys follow the focused pane.", style: dimStyle}, {text: "", style: lipgloss.NewStyle()}}
|
||||||
|
for _, pair := range pairs {
|
||||||
|
rows = append(rows, row{
|
||||||
|
text: splitLine(" "+pair[0], pair[1], 52),
|
||||||
|
style: lipgloss.NewStyle(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) publishRows() []row {
|
||||||
|
sub, _ := m.currentSub()
|
||||||
|
labels := []string{"Title", "Priority", "Tags", "Message"}
|
||||||
|
rows := []row{{text: " to " + sub.Label(), style: dimStyle}, {text: "", style: lipgloss.NewStyle()}}
|
||||||
|
for i, label := range labels {
|
||||||
|
style := dimStyle
|
||||||
|
if i == m.formIdx {
|
||||||
|
style = accentStyle
|
||||||
|
}
|
||||||
|
rows = append(rows, row{text: " " + label, style: style})
|
||||||
|
rows = append(rows, row{text: " " + m.form[i].View(), raw: true})
|
||||||
|
rows = append(rows, row{text: "", style: lipgloss.NewStyle()})
|
||||||
|
}
|
||||||
|
rows = append(rows, row{text: " enter sends tab moves esc cancels", style: dimStyle})
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) addRows() []row {
|
||||||
|
return []row{
|
||||||
|
{text: " Short names use your default host.", style: dimStyle},
|
||||||
|
{text: "", style: lipgloss.NewStyle()},
|
||||||
|
{text: " Topic", style: accentStyle},
|
||||||
|
{text: " " + m.addInput.View(), raw: true},
|
||||||
|
{text: "", style: lipgloss.NewStyle()},
|
||||||
|
{text: " enter subscribes esc cancels", style: dimStyle},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) modal(title string, rows []row) string {
|
||||||
|
width := m.width - 8
|
||||||
|
if width > 68 {
|
||||||
|
width = 68
|
||||||
|
}
|
||||||
|
if width < 36 {
|
||||||
|
width = m.width
|
||||||
|
}
|
||||||
|
if width > m.width {
|
||||||
|
width = m.width
|
||||||
|
}
|
||||||
|
height := len(rows) + 2
|
||||||
|
if height > m.height-2 {
|
||||||
|
height = m.height - 2
|
||||||
|
}
|
||||||
|
if height < 5 {
|
||||||
|
height = 5
|
||||||
|
}
|
||||||
|
card := box(title, true, width, height, rows)
|
||||||
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) statusStyle() lipgloss.Style {
|
||||||
|
s := strings.ToLower(m.status)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(s, "not authorized") || strings.Contains(s, "http 4") || strings.Contains(s, "http 5"):
|
||||||
|
return badStyle
|
||||||
|
case strings.Contains(s, "disconnect") || strings.Contains(s, "no topic") || strings.Contains(s, "retry"):
|
||||||
|
return warnStyle
|
||||||
|
case strings.Contains(s, "connected") || strings.HasPrefix(s, "published"):
|
||||||
|
return okStyle
|
||||||
|
default:
|
||||||
|
return dimStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func box(title string, active bool, width, height int, rows []row) string {
|
||||||
|
if width < 2 || height < 2 {
|
||||||
|
return strings.Repeat("\n", max(0, height-1))
|
||||||
|
}
|
||||||
|
innerW := width - 2
|
||||||
|
innerH := height - 2
|
||||||
|
border := dimStyle
|
||||||
|
if active {
|
||||||
|
border = accentStyle
|
||||||
|
}
|
||||||
|
label := " " + title + " "
|
||||||
|
if len([]rune(label)) > innerW {
|
||||||
|
label = textutil.Clip(title, innerW)
|
||||||
|
}
|
||||||
|
pad := innerW - len([]rune(label))
|
||||||
|
if pad < 0 {
|
||||||
|
pad = 0
|
||||||
|
}
|
||||||
|
left := 1
|
||||||
|
if pad == 0 {
|
||||||
|
left = 0
|
||||||
|
}
|
||||||
|
right := pad - left
|
||||||
|
top := border.Render("╭"+strings.Repeat("─", left)) + titleStyle.Render(label) + border.Render(strings.Repeat("─", right)+"╮")
|
||||||
|
lines := []string{top}
|
||||||
|
for i := 0; i < innerH; i++ {
|
||||||
|
var painted string
|
||||||
|
if i < len(rows) && rows[i].raw {
|
||||||
|
painted = fitWidth(rows[i].text, innerW)
|
||||||
|
} else {
|
||||||
|
text := ""
|
||||||
|
style := lipgloss.NewStyle()
|
||||||
|
if i < len(rows) {
|
||||||
|
text = rows[i].text
|
||||||
|
style = rows[i].style
|
||||||
|
}
|
||||||
|
painted = style.Render(textutil.Pad(textutil.Clip(text, innerW), innerW))
|
||||||
|
}
|
||||||
|
lines = append(lines, border.Render("│")+painted+border.Render("│"))
|
||||||
|
}
|
||||||
|
lines = append(lines, border.Render("╰"+strings.Repeat("─", innerW)+"╯"))
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func gapColumn(height int) string {
|
||||||
|
if height < 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lines := make([]string, height)
|
||||||
|
for i := range lines {
|
||||||
|
lines[i] = " "
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func spread(left, right string, width int) string {
|
||||||
|
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||||
|
if gap < 1 {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
return left + strings.Repeat(" ", gap) + right
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLine(left, right string, width int) string {
|
||||||
|
if width <= 0 {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
gap := width - len([]rune(left)) - len([]rune(right))
|
||||||
|
if gap < 1 {
|
||||||
|
return textutil.Clip(left+" "+right, width)
|
||||||
|
}
|
||||||
|
return left + strings.Repeat(" ", gap) + right
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitStyled(left, right string, width int) string {
|
||||||
|
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||||
|
if gap < 1 {
|
||||||
|
return fitWidth(left, width)
|
||||||
|
}
|
||||||
|
return left + strings.Repeat(" ", gap) + right
|
||||||
|
}
|
||||||
|
|
||||||
|
func fitWidth(s string, width int) string {
|
||||||
|
w := lipgloss.Width(s)
|
||||||
|
if w > width && width > 0 {
|
||||||
|
// Styled input lines are already width-limited by the text field.
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if gap := width - w; gap > 0 {
|
||||||
|
return s + strings.Repeat(" ", gap)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func toneStyle(tone string) lipgloss.Style {
|
||||||
|
switch tone {
|
||||||
|
case "bad":
|
||||||
|
return badStyle
|
||||||
|
case "ok":
|
||||||
|
return okStyle
|
||||||
|
case "warn":
|
||||||
|
return warnStyle
|
||||||
|
case "dim":
|
||||||
|
return dimStyle
|
||||||
|
default:
|
||||||
|
return lipgloss.NewStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
titleStyle = theme.Title
|
||||||
|
accentStyle = theme.Accent
|
||||||
|
badgeStyle = theme.Badge
|
||||||
|
keyStyle = theme.Key
|
||||||
|
filterStyle = theme.Filter
|
||||||
|
dimStyle = theme.Dim
|
||||||
|
okStyle = theme.OK
|
||||||
|
warnStyle = theme.Warn
|
||||||
|
badStyle = theme.Bad
|
||||||
|
selectedStyle = theme.Selected
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user