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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user