Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
fcc6f3eb4c
10 changed files with 1002 additions and 0 deletions
266
model.go
Normal file
266
model.go
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// ── App state ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type appState int
|
||||
|
||||
const (
|
||||
stateList appState = iota
|
||||
stateMessage appState = iota
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Model struct {
|
||||
state appState
|
||||
list list.Model
|
||||
progress progress.Model
|
||||
viewport viewport.Model
|
||||
entries []QueueEntry
|
||||
entryIndex map[string]int // queue ID → entries index
|
||||
loadingTotal int
|
||||
loadingDone int
|
||||
currentRaw string
|
||||
currentID string
|
||||
showFullHeaders bool
|
||||
err error
|
||||
saveNotice string
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func initialModel() Model {
|
||||
l := list.New(nil, itemDelegate{}, 80, 20)
|
||||
l.Title = "Postfix Queue"
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetFilteringEnabled(false)
|
||||
l.Styles.Title = titleStyle
|
||||
l.SetShowHelp(false)
|
||||
|
||||
prog := progress.New(
|
||||
progress.WithDefaultGradient(),
|
||||
progress.WithoutPercentage(),
|
||||
)
|
||||
|
||||
vp := viewport.New(80, 20)
|
||||
|
||||
return Model{
|
||||
state: stateList,
|
||||
list: l,
|
||||
progress: prog,
|
||||
viewport: vp,
|
||||
}
|
||||
}
|
||||
|
||||
// refreshViewport re-renders the current message and sets wrapped content.
|
||||
func (m *Model) refreshViewport() {
|
||||
rendered := renderMessage(m.currentRaw, m.showFullHeaders)
|
||||
headerSection, body, _ := strings.Cut(rendered, "\n\n")
|
||||
|
||||
var result []string
|
||||
for line := range strings.SplitSeq(headerSection, "\n") {
|
||||
parts := strings.Split(ansi.Wrap(line, m.viewport.Width-4, ""), "\n")
|
||||
result = append(result, parts[0])
|
||||
for _, continuation := range parts[1:] {
|
||||
result = append(result, " "+continuation)
|
||||
}
|
||||
}
|
||||
result = append(result, "")
|
||||
result = append(result, strings.Split(ansi.Wrap(body, m.viewport.Width, ""), "\n")...)
|
||||
m.viewport.SetContent(strings.Join(result, "\n"))
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return loadQueueCmd()
|
||||
}
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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.progress.Width = msg.Width - 4
|
||||
m.list.SetSize(msg.Width, msg.Height-2)
|
||||
m.viewport.Width = msg.Width
|
||||
m.viewport.Height = msg.Height - 2
|
||||
if m.state == stateMessage && m.currentRaw != "" {
|
||||
m.refreshViewport()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case queueParsedMsg:
|
||||
m.entries = msg.entries
|
||||
m.entryIndex = make(map[string]int, len(msg.entries))
|
||||
m.loadingTotal = len(msg.entries)
|
||||
m.loadingDone = 0
|
||||
m.err = nil
|
||||
|
||||
items := make([]list.Item, len(msg.entries))
|
||||
for i, e := range msg.entries {
|
||||
items[i] = queueItem{e}
|
||||
m.entryIndex[e.ID] = i
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
|
||||
if m.loadingTotal == 0 {
|
||||
return m, nil
|
||||
}
|
||||
cmds := make([]tea.Cmd, len(msg.entries))
|
||||
for i, e := range msg.entries {
|
||||
cmds[i] = fetchSubjectCmd(e.ID)
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case subjectFetchedMsg:
|
||||
if idx, ok := m.entryIndex[msg.id]; ok {
|
||||
m.entries[idx].Subject = msg.subject
|
||||
m.list.SetItem(idx, queueItem{m.entries[idx]})
|
||||
}
|
||||
m.loadingDone++
|
||||
pct := float64(m.loadingDone) / float64(m.loadingTotal)
|
||||
return m, m.progress.SetPercent(pct)
|
||||
|
||||
case progress.FrameMsg:
|
||||
pm, cmd := m.progress.Update(msg)
|
||||
m.progress = pm.(progress.Model)
|
||||
return m, cmd
|
||||
|
||||
case queueErrMsg:
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
|
||||
case messageLoadedMsg:
|
||||
m.currentRaw = msg.content
|
||||
m.currentID = msg.id
|
||||
m.showFullHeaders = false
|
||||
m.refreshViewport()
|
||||
m.viewport.GotoTop()
|
||||
m.state = stateMessage
|
||||
return m, nil
|
||||
|
||||
case messageErrMsg:
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
|
||||
case savedMsg:
|
||||
m.saveNotice = "Saved: " + msg.path
|
||||
return m, nil
|
||||
|
||||
case saveErrMsg:
|
||||
m.saveNotice = "Save error: " + msg.err.Error()
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.state {
|
||||
|
||||
case stateList:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "r":
|
||||
m.entries = nil
|
||||
m.entryIndex = nil
|
||||
m.loadingTotal = 0
|
||||
m.loadingDone = 0
|
||||
m.list.SetItems(nil)
|
||||
_ = m.progress.SetPercent(0)
|
||||
return m, loadQueueCmd()
|
||||
case "enter":
|
||||
if item, ok := m.list.SelectedItem().(queueItem); ok {
|
||||
return m, loadMessageCmd(item.entry.ID)
|
||||
}
|
||||
}
|
||||
|
||||
case stateMessage:
|
||||
switch msg.String() {
|
||||
case "q":
|
||||
m.state = stateList
|
||||
m.saveNotice = ""
|
||||
return m, nil
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "s":
|
||||
return m, saveCmd(m.currentID, m.currentRaw)
|
||||
case "H":
|
||||
m.showFullHeaders = !m.showFullHeaders
|
||||
m.refreshViewport()
|
||||
m.viewport.GotoTop()
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.state {
|
||||
case stateList:
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
case stateMessage:
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// ── View ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.err != nil {
|
||||
return errorStyle.Render("Error: "+m.err.Error()) + "\n\nPress q to quit."
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
|
||||
case stateList:
|
||||
if m.entries == nil {
|
||||
return titleStyle.Render("Postfix Queue") + "\n\n" +
|
||||
dimStyle.Render(" Fetching queue list…")
|
||||
}
|
||||
return m.list.View() + "\n" + m.renderBottom()
|
||||
|
||||
case stateMessage:
|
||||
header := titleStyle.Render(fmt.Sprintf("Message: %s", m.currentID))
|
||||
scrollPct := int(m.viewport.ScrollPercent() * 100)
|
||||
headersHint := "H: full headers"
|
||||
if m.showFullHeaders {
|
||||
headersHint = "H: short headers"
|
||||
}
|
||||
status := statusBarStyle.Render(
|
||||
fmt.Sprintf(" ↑↓/SPC/PgUp/Dn: scroll │ s: save EML │ %s │ q: back │ %d%% ", headersHint, scrollPct),
|
||||
)
|
||||
notice := ""
|
||||
if m.saveNotice != "" {
|
||||
notice = "\n" + saveNoticeStyle.Render(m.saveNotice)
|
||||
}
|
||||
return header + "\n" + m.viewport.View() + "\n" + status + notice
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m Model) renderBottom() string {
|
||||
if m.loadingDone < m.loadingTotal {
|
||||
label := fmt.Sprintf(" Fetching subjects: %d / %d ", m.loadingDone, m.loadingTotal)
|
||||
return dimStyle.Render(label) + "\n " + m.progress.View()
|
||||
}
|
||||
return statusBarStyle.Render(
|
||||
fmt.Sprintf(" %d message(s) │ Enter: open │ r: refresh │ q: quit ", len(m.list.Items())),
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue