- 添加邮件详情面板显示(主题、发件人、收件人、抄送、账户、时间、正文) - 优化邮件列表卡片样式,增加选中高亮效果 - 窗口宽度 >= 80 时启用双面板布局,左侧列表右侧详情 - 简化依赖包,从 charm.land 使用统一导入路径 - 删除未使用的 golangci/goreleaser 配置文件
482 lines
12 KiB
Go
482 lines
12 KiB
Go
package inbox
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/bubbles/v2/key"
|
|
"charm.land/bubbles/v2/list"
|
|
"charm.land/bubbles/v2/spinner"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
|
|
_ "charm.land/bubbles/v2/help"
|
|
)
|
|
|
|
type EmailDetail struct {
|
|
UID uint32
|
|
From string
|
|
FromName string
|
|
To string
|
|
Cc string
|
|
Subject string
|
|
Date time.Time
|
|
TextBody string
|
|
HTMLBody string
|
|
Account string
|
|
AccountID string
|
|
}
|
|
|
|
type DetailResultMsg struct {
|
|
Detail *EmailDetail
|
|
Err error
|
|
}
|
|
|
|
type EmailItem struct {
|
|
UID uint32
|
|
From string
|
|
FromName string
|
|
Subject string
|
|
Date time.Time
|
|
Preview string
|
|
Account string
|
|
AccountID string
|
|
}
|
|
|
|
func (e EmailItem) FilterValue() string {
|
|
return fmt.Sprintf("%s %s %s %s", e.Account, e.FromName, e.From, e.Subject)
|
|
}
|
|
|
|
func (e EmailItem) Title() string {
|
|
return fmt.Sprintf("%s · %s - %s", e.Account, e.FromName, e.Subject)
|
|
}
|
|
|
|
func (e EmailItem) Description() string {
|
|
return e.Preview
|
|
}
|
|
|
|
func formatTimeAgo(d time.Duration) string {
|
|
if d < time.Minute {
|
|
return "刚刚"
|
|
}
|
|
if d < time.Hour {
|
|
mins := int(d.Minutes())
|
|
return fmt.Sprintf("%d分钟前", mins)
|
|
}
|
|
if d < 24*time.Hour {
|
|
hours := int(d.Hours())
|
|
return fmt.Sprintf("%d小时前", hours)
|
|
}
|
|
if d < 7*24*time.Hour {
|
|
days := int(d.Hours() / 24)
|
|
return fmt.Sprintf("%d天前", days)
|
|
}
|
|
return d.Truncate(24 * time.Hour).String()
|
|
}
|
|
|
|
func truncateString(s string, maxWidth int) string {
|
|
if maxWidth <= 0 {
|
|
return ""
|
|
}
|
|
runes := []rune(s)
|
|
if len(runes) <= maxWidth {
|
|
return s
|
|
}
|
|
if maxWidth <= 3 {
|
|
return strings.Repeat(".", maxWidth)
|
|
}
|
|
return string(runes[:maxWidth-3]) + "..."
|
|
}
|
|
|
|
type InboxModel struct {
|
|
list list.Model
|
|
emails []EmailItem
|
|
loading bool
|
|
err error
|
|
selectedEmail *EmailItem
|
|
selectedDetail *EmailDetail
|
|
loadingDetail bool
|
|
windowWidth int
|
|
windowHeight int
|
|
helpKeyMap inboxHelpKeyMap
|
|
spinner spinner.Model
|
|
detailFetcher DetailFetcher
|
|
}
|
|
|
|
type inboxHelpKeyMap struct{}
|
|
|
|
func (i inboxHelpKeyMap) ShortHelp() []key.Binding {
|
|
return []key.Binding{
|
|
key.NewBinding(key.WithHelp("↑", "上移")),
|
|
key.NewBinding(key.WithHelp("↓", "下移")),
|
|
key.NewBinding(key.WithHelp("enter", "查看详情")),
|
|
key.NewBinding(key.WithHelp("/", "搜索")),
|
|
key.NewBinding(key.WithHelp("q", "退出")),
|
|
}
|
|
}
|
|
|
|
func (i inboxHelpKeyMap) FullHelp() [][]key.Binding {
|
|
return [][]key.Binding{
|
|
{
|
|
key.NewBinding(key.WithHelp("↑↓", "移动")),
|
|
key.NewBinding(key.WithHelp("enter", "查看详情")),
|
|
key.NewBinding(key.WithHelp("/", "搜索")),
|
|
key.NewBinding(key.WithHelp("q", "退出")),
|
|
},
|
|
}
|
|
}
|
|
|
|
func NewInboxModel() *InboxModel {
|
|
l := list.New(nil, emailDelegate{}, 40, 14)
|
|
l.Title = "收件箱"
|
|
l.Styles = list.DefaultStyles(true)
|
|
l.Styles.Title = inboxTitleStyle
|
|
l.Styles.NoItems = inboxNoItemsStyle
|
|
l.SetShowHelp(true)
|
|
l.SetShowPagination(false)
|
|
l.SetFilteringEnabled(true)
|
|
|
|
s := spinner.New()
|
|
s.Spinner = spinner.Dot
|
|
s.Style = spinnerStyle
|
|
|
|
return &InboxModel{
|
|
list: l,
|
|
emails: []EmailItem{},
|
|
windowWidth: 0,
|
|
spinner: s,
|
|
}
|
|
}
|
|
|
|
func (m *InboxModel) SetEmails(emails []EmailItem) {
|
|
m.emails = emails
|
|
items := make([]list.Item, len(emails))
|
|
for i, e := range emails {
|
|
items[i] = e
|
|
}
|
|
m.list.SetItems(items)
|
|
m.list.Title = fmt.Sprintf("收件箱 (%d 封新邮件)", len(items))
|
|
}
|
|
|
|
func (m InboxModel) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
func() tea.Msg {
|
|
return tea.WindowSizeMsg{Width: 120, Height: 30}
|
|
},
|
|
m.spinner.Tick,
|
|
)
|
|
}
|
|
|
|
func fetchEmailDetail(accountID string, uid uint32, fetcher DetailFetcher) tea.Cmd {
|
|
return func() tea.Msg {
|
|
detail, err := fetcher(accountID, uid)
|
|
return DetailResultMsg{Detail: detail, Err: err}
|
|
}
|
|
}
|
|
|
|
func (m *InboxModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmd tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
if msg.String() == "ctrl+c" {
|
|
return m, tea.Quit
|
|
}
|
|
if msg.String() == "enter" {
|
|
email := m.SelectedEmail()
|
|
if email != nil && m.detailFetcher != nil {
|
|
m.loadingDetail = true
|
|
m.selectedDetail = nil
|
|
return m, fetchEmailDetail(email.AccountID, email.UID, m.detailFetcher)
|
|
}
|
|
}
|
|
case tea.WindowSizeMsg:
|
|
m.windowWidth = msg.Width
|
|
m.windowHeight = msg.Height
|
|
m.list.SetWidth(calculateListWidth(msg.Width))
|
|
m.list.SetHeight(int(float64(msg.Height) * 0.8))
|
|
case DetailResultMsg:
|
|
m.loadingDetail = false
|
|
if msg.Err != nil {
|
|
m.err = msg.Err
|
|
} else {
|
|
m.selectedDetail = msg.Detail
|
|
}
|
|
case spinner.TickMsg:
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
m.list, cmd = m.list.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func calculateListWidth(totalWidth int) int {
|
|
if totalWidth <= 0 {
|
|
return 0
|
|
}
|
|
return int(float64(totalWidth) * 0.45)
|
|
}
|
|
|
|
func (m InboxModel) View() tea.View {
|
|
if m.loading {
|
|
return tea.NewView(loadingStyle.Render("正在加载邮件..."))
|
|
}
|
|
if m.err != nil {
|
|
return tea.NewView(errorStyle.Render(fmt.Sprintf("错误: %v", m.err)))
|
|
}
|
|
|
|
listWidth := calculateListWidth(m.windowWidth)
|
|
detailWidth := m.windowWidth - listWidth - 1
|
|
|
|
m.list.SetWidth(listWidth)
|
|
|
|
mainContentStyle := lipgloss.NewStyle().
|
|
Width(m.windowWidth - 1).
|
|
Height(m.windowHeight - 2)
|
|
|
|
helpBarStyle := lipgloss.NewStyle().
|
|
Width(m.windowWidth - 1).
|
|
Foreground(lipgloss.Color("241"))
|
|
|
|
helpView := helpBarStyle.Render(m.list.Help.View(m.helpKeyMap))
|
|
|
|
if m.windowWidth >= 80 {
|
|
detailView := m.renderDetailPanel(detailWidth)
|
|
listView := m.list.View()
|
|
mainContent := lipgloss.JoinHorizontal(lipgloss.Top, listView, detailView)
|
|
box := mainContentStyle.Render(mainContent)
|
|
content := lipgloss.JoinVertical(lipgloss.Bottom, box, helpView)
|
|
v := tea.NewView(content)
|
|
v.AltScreen = true
|
|
return v
|
|
}
|
|
|
|
listView := m.list.View()
|
|
box := mainContentStyle.Render(listView)
|
|
content := lipgloss.JoinVertical(lipgloss.Bottom, box, helpView)
|
|
v := tea.NewView(content)
|
|
v.AltScreen = true
|
|
return v
|
|
}
|
|
|
|
func (m InboxModel) renderHelpBar() string {
|
|
helpItems := "↑↓ 移动 │ enter 查看详情 │ / 搜索 │ q 退出"
|
|
return helpBarStyle.Render(helpItems)
|
|
}
|
|
|
|
func (m InboxModel) renderDetailPanel(listWidth int) string {
|
|
email := m.SelectedEmail()
|
|
if email == nil {
|
|
return detailPanelStyle.Render("选择一封邮件查看详情")
|
|
}
|
|
|
|
if m.loadingDetail {
|
|
spinnerView := m.spinner.View()
|
|
loadingText := detailMetaStyle.Render(spinnerView + " 正在加载邮件内容...")
|
|
return detailPanelStyle.Width(40).Render(loadingText)
|
|
}
|
|
|
|
if m.selectedDetail != nil {
|
|
detail := m.selectedDetail
|
|
subject := detailTitleStyle.Render(detail.Subject)
|
|
|
|
from := detailMetaStyle.Render(fmt.Sprintf("发件人: %s", detail.From))
|
|
|
|
to := detailMetaStyle.Render(fmt.Sprintf("收件人: %s", detail.To))
|
|
if detail.Cc != "" {
|
|
to += "\n" + detailMetaStyle.Render(fmt.Sprintf("抄送: %s", detail.Cc))
|
|
}
|
|
account := detailMetaStyle.Render(fmt.Sprintf("账户: %s", detail.Account))
|
|
date := detailMetaStyle.Render(fmt.Sprintf("时间: %s", detail.Date.Format("2006-01-02 15:04")))
|
|
|
|
var body string
|
|
if detail.TextBody != "" {
|
|
body = detailBodyStyle.Render(detail.TextBody)
|
|
} else if detail.HTMLBody != "" {
|
|
body = detailMetaStyle.Render("[HTML邮件内容]")
|
|
} else {
|
|
body = detailMetaStyle.Render("[无正文]")
|
|
}
|
|
|
|
content := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n\n%s", subject, from, to, account, date, body)
|
|
|
|
detailWidth := 40
|
|
if m.windowWidth > 0 {
|
|
detailWidth = m.windowWidth - listWidth - 1
|
|
}
|
|
if detailWidth < 10 {
|
|
detailWidth = 40
|
|
}
|
|
|
|
return detailPanelStyle.Width(detailWidth).Render(content)
|
|
}
|
|
|
|
subject := detailTitleStyle.Render(email.Subject)
|
|
from := detailMetaStyle.Render(fmt.Sprintf("发件人: %s <%s>", email.FromName, email.From))
|
|
account := detailMetaStyle.Render(fmt.Sprintf("账户: %s", email.Account))
|
|
date := detailMetaStyle.Render(fmt.Sprintf("时间: %s", email.Date.Format("2006-01-02 15:04")))
|
|
preview := detailPanelStyle.Render(email.Preview)
|
|
|
|
content := fmt.Sprintf("%s\n%s\n%s\n%s\n\n%s", subject, from, account, date, preview)
|
|
|
|
detailWidth := 40
|
|
if m.windowWidth > 0 {
|
|
detailWidth = m.windowWidth - listWidth - 1
|
|
}
|
|
if detailWidth < 10 {
|
|
detailWidth = 40
|
|
}
|
|
|
|
return detailPanelStyle.Width(detailWidth).Render(content)
|
|
}
|
|
|
|
func (m *InboxModel) SetLoading(loading bool) {
|
|
m.loading = loading
|
|
}
|
|
|
|
func (m *InboxModel) SetError(err error) {
|
|
m.err = err
|
|
}
|
|
|
|
func (m InboxModel) SelectedEmail() *EmailItem {
|
|
idx := m.list.Index()
|
|
if idx >= 0 && idx < len(m.emails) {
|
|
return &m.emails[idx]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
inboxTitleStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("219")).
|
|
Bold(true)
|
|
|
|
inboxNoItemsStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("241")).
|
|
Padding(1, 2)
|
|
|
|
loadingStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("219")).
|
|
Padding(1, 2)
|
|
|
|
errorStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("196")).
|
|
Padding(1, 2)
|
|
|
|
emailCardSubjectStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("255")).
|
|
Bold(true).
|
|
Padding(0, 2)
|
|
|
|
emailCardMetaStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("241")).
|
|
Padding(0, 2)
|
|
|
|
emailCardSubjectSelectedStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("255")).
|
|
Bold(true).
|
|
Background(lipgloss.Color("99")).
|
|
Border(lipgloss.NormalBorder()).
|
|
BorderLeft(true).
|
|
BorderForeground(lipgloss.Color("219")).
|
|
Padding(0, 2)
|
|
|
|
emailCardMetaSelectedStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("186")).
|
|
Background(lipgloss.Color("99")).
|
|
Border(lipgloss.NormalBorder()).
|
|
BorderLeft(true).
|
|
BorderForeground(lipgloss.Color("219")).
|
|
Padding(0, 2)
|
|
|
|
detailPanelStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("255")).
|
|
Padding(1, 2)
|
|
|
|
detailTitleStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("219")).
|
|
Bold(true).
|
|
Padding(0, 1)
|
|
|
|
detailMetaStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("241")).
|
|
Padding(0, 1)
|
|
|
|
detailBodyStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("252")).
|
|
Padding(0, 1)
|
|
|
|
helpBarStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("241")).
|
|
Padding(0, 1)
|
|
|
|
spinnerStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("219"))
|
|
|
|
cardSpacing = 1
|
|
)
|
|
|
|
type DetailFetcher func(accountID string, uid uint32) (*EmailDetail, error)
|
|
|
|
func (m *InboxModel) SetDetailFetcher(fetcher DetailFetcher) {
|
|
m.detailFetcher = fetcher
|
|
}
|
|
|
|
type emailDelegate struct{}
|
|
|
|
func (d emailDelegate) Height() int { return 2 }
|
|
func (d emailDelegate) Spacing() int { return 0 }
|
|
func (d emailDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
|
|
return nil
|
|
}
|
|
func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
|
|
email, ok := item.(EmailItem)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
isSelected := index == m.Index()
|
|
|
|
width := m.Width() - 1
|
|
subjectWidth := width - 2
|
|
metaWidth := width - 2
|
|
|
|
subject := truncateString(email.Subject, subjectWidth)
|
|
meta := truncateString("▣ "+email.Account+" · "+formatTimeAgo(time.Since(email.Date)), metaWidth)
|
|
|
|
if isSelected {
|
|
subjectStyle := lipgloss.NewStyle().
|
|
Width(width).
|
|
Foreground(lipgloss.Color("255")).
|
|
Background(lipgloss.Color("99")).
|
|
Padding(1, 1, 0, 1)
|
|
|
|
metaStyle := lipgloss.NewStyle().
|
|
Width(width).
|
|
Foreground(lipgloss.Color("186")).
|
|
Background(lipgloss.Color("99")).
|
|
Padding(0, 1, 1, 1)
|
|
|
|
fmt.Fprintf(w, "%s\n%s",
|
|
subjectStyle.Render(subject),
|
|
metaStyle.Render(meta))
|
|
} else {
|
|
subjectStyle := lipgloss.NewStyle().
|
|
Width(width).
|
|
Foreground(lipgloss.Color("255")).
|
|
Padding(1, 1, 0, 1)
|
|
|
|
metaStyle := lipgloss.NewStyle().
|
|
Width(width).
|
|
Foreground(lipgloss.Color("241")).
|
|
Padding(0, 1, 1, 1)
|
|
|
|
fmt.Fprintf(w, "%s\n%s",
|
|
subjectStyle.Render(subject),
|
|
metaStyle.Render(meta))
|
|
}
|
|
}
|