mirror of
https://git.sr.ht/~rjarry/aerc
synced 2026-03-06 18:23:44 +01:00
Change the parts switcher so it displays the file names first (if any) and then the mime types. The mime types are aligned to the right to help the file names to be more visible. If there is no file name - the mime type is displayed on the left, as usual. The idea is that the file name (if present) is more important than the mime type. Especially when both file names and mime types are long - this quickly becomes a mess. If there is no space for both file name and mime, the mime type is truncated with ellipsis. If there is no space for mime at all - it is dropped completely. If then there is no space for the file name, it is also truncated with ellipsis. Signed-off-by: Vitaly Ovchinnikov <v@postbox.nz> Signed-off-by: Robin Jarry <robin@jarry.cc> Tested-by: Inwit <inwit@sindominio.net>
942 lines
23 KiB
Go
942 lines
23 KiB
Go
package widgets
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"github.com/danwakefield/fnmatch"
|
|
"github.com/emersion/go-message/textproto"
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/google/shlex"
|
|
"github.com/mattn/go-runewidth"
|
|
|
|
"git.sr.ht/~rjarry/aerc/config"
|
|
"git.sr.ht/~rjarry/aerc/lib"
|
|
"git.sr.ht/~rjarry/aerc/lib/auth"
|
|
"git.sr.ht/~rjarry/aerc/lib/format"
|
|
"git.sr.ht/~rjarry/aerc/lib/parse"
|
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
|
"git.sr.ht/~rjarry/aerc/log"
|
|
"git.sr.ht/~rjarry/aerc/models"
|
|
)
|
|
|
|
var _ ProvidesMessages = (*MessageViewer)(nil)
|
|
|
|
type MessageViewer struct {
|
|
acct *AccountView
|
|
err error
|
|
grid *ui.Grid
|
|
switcher *PartSwitcher
|
|
msg lib.MessageView
|
|
uiConfig *config.UIConfig
|
|
}
|
|
|
|
type PartSwitcher struct {
|
|
parts []*PartViewer
|
|
selected int
|
|
alwaysShowMime bool
|
|
|
|
height int
|
|
mv *MessageViewer
|
|
}
|
|
|
|
func NewMessageViewer(
|
|
acct *AccountView, msg lib.MessageView,
|
|
) *MessageViewer {
|
|
if msg == nil {
|
|
return &MessageViewer{
|
|
acct: acct,
|
|
err: fmt.Errorf("(no message selected)"),
|
|
}
|
|
}
|
|
hf := HeaderLayoutFilter{
|
|
layout: HeaderLayout(config.Viewer.HeaderLayout),
|
|
keep: func(msg *models.MessageInfo, header string) bool {
|
|
return fmtHeader(msg, header, "2", "3", "4", "5") != ""
|
|
},
|
|
}
|
|
layout := hf.forMessage(msg.MessageInfo())
|
|
header, headerHeight := layout.grid(
|
|
func(header string) ui.Drawable {
|
|
hv := &HeaderView{
|
|
Name: header,
|
|
Value: fmtHeader(
|
|
msg.MessageInfo(),
|
|
header,
|
|
acct.UiConfig().MessageViewTimestampFormat,
|
|
acct.UiConfig().MessageViewThisDayTimeFormat,
|
|
acct.UiConfig().MessageViewThisWeekTimeFormat,
|
|
acct.UiConfig().MessageViewThisYearTimeFormat,
|
|
),
|
|
uiConfig: acct.UiConfig(),
|
|
}
|
|
showInfo := false
|
|
if i := strings.IndexRune(header, '+'); i > 0 {
|
|
header = header[:i]
|
|
hv.Name = header
|
|
showInfo = true
|
|
}
|
|
if parser := auth.New(header); parser != nil && msg.MessageInfo().Error == nil {
|
|
details, err := parser(msg.MessageInfo().RFC822Headers, acct.AccountConfig().TrustedAuthRes)
|
|
if err != nil {
|
|
hv.Value = err.Error()
|
|
} else {
|
|
hv.ValueField = NewAuthInfo(details, showInfo, acct.UiConfig())
|
|
}
|
|
hv.Invalidate()
|
|
}
|
|
return hv
|
|
},
|
|
)
|
|
|
|
rows := []ui.GridSpec{
|
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(headerHeight)},
|
|
}
|
|
|
|
if msg.MessageDetails() != nil || acct.UiConfig().IconUnencrypted != "" {
|
|
height := 1
|
|
if msg.MessageDetails() != nil && msg.MessageDetails().IsSigned && msg.MessageDetails().IsEncrypted {
|
|
height = 2
|
|
}
|
|
rows = append(rows, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(height)})
|
|
}
|
|
|
|
rows = append(rows, []ui.GridSpec{
|
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
|
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
|
}...)
|
|
|
|
grid := ui.NewGrid().Rows(rows).Columns([]ui.GridSpec{
|
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
|
})
|
|
|
|
switcher := &PartSwitcher{}
|
|
err := createSwitcher(acct, switcher, msg)
|
|
if err != nil {
|
|
return &MessageViewer{
|
|
acct: acct,
|
|
err: err,
|
|
grid: grid,
|
|
msg: msg,
|
|
uiConfig: acct.UiConfig(),
|
|
}
|
|
}
|
|
|
|
borderStyle := acct.UiConfig().GetStyle(config.STYLE_BORDER)
|
|
borderChar := acct.UiConfig().BorderCharHorizontal
|
|
|
|
grid.AddChild(header).At(0, 0)
|
|
if msg.MessageDetails() != nil || acct.UiConfig().IconUnencrypted != "" {
|
|
grid.AddChild(NewPGPInfo(msg.MessageDetails(), acct.UiConfig())).At(1, 0)
|
|
grid.AddChild(ui.NewFill(borderChar, borderStyle)).At(2, 0)
|
|
grid.AddChild(switcher).At(3, 0)
|
|
} else {
|
|
grid.AddChild(ui.NewFill(borderChar, borderStyle)).At(1, 0)
|
|
grid.AddChild(switcher).At(2, 0)
|
|
}
|
|
|
|
mv := &MessageViewer{
|
|
acct: acct,
|
|
grid: grid,
|
|
msg: msg,
|
|
switcher: switcher,
|
|
uiConfig: acct.UiConfig(),
|
|
}
|
|
switcher.mv = mv
|
|
|
|
return mv
|
|
}
|
|
|
|
func fmtHeader(msg *models.MessageInfo, header string,
|
|
timefmt string, todayFormat string, thisWeekFormat string, thisYearFormat string,
|
|
) string {
|
|
if msg == nil || msg.Envelope == nil {
|
|
return "error: no envelope for this message"
|
|
}
|
|
|
|
if v := auth.New(header); v != nil {
|
|
return "Fetching.."
|
|
}
|
|
|
|
switch header {
|
|
case "From":
|
|
return format.FormatAddresses(msg.Envelope.From)
|
|
case "To":
|
|
return format.FormatAddresses(msg.Envelope.To)
|
|
case "Cc":
|
|
return format.FormatAddresses(msg.Envelope.Cc)
|
|
case "Bcc":
|
|
return format.FormatAddresses(msg.Envelope.Bcc)
|
|
case "Date":
|
|
return format.DummyIfZeroDate(
|
|
msg.Envelope.Date.Local(),
|
|
timefmt,
|
|
todayFormat,
|
|
thisWeekFormat,
|
|
thisYearFormat,
|
|
)
|
|
case "Subject":
|
|
return msg.Envelope.Subject
|
|
case "Labels":
|
|
return strings.Join(msg.Labels, ", ")
|
|
default:
|
|
return msg.RFC822Headers.Get(header)
|
|
}
|
|
}
|
|
|
|
func enumerateParts(
|
|
acct *AccountView, msg lib.MessageView,
|
|
body *models.BodyStructure, index []int,
|
|
) ([]*PartViewer, error) {
|
|
var parts []*PartViewer
|
|
for i, part := range body.Parts {
|
|
curindex := append(index, i+1) //nolint:gocritic // intentional append to different slice
|
|
if part.MIMEType == "multipart" {
|
|
// Multipart meta-parts are faked
|
|
pv := &PartViewer{part: part}
|
|
parts = append(parts, pv)
|
|
subParts, err := enumerateParts(
|
|
acct, msg, part, curindex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parts = append(parts, subParts...)
|
|
continue
|
|
}
|
|
pv, err := NewPartViewer(acct, msg, part, curindex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parts = append(parts, pv)
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
func createSwitcher(
|
|
acct *AccountView, switcher *PartSwitcher, msg lib.MessageView,
|
|
) error {
|
|
var err error
|
|
switcher.selected = -1
|
|
switcher.alwaysShowMime = config.Viewer.AlwaysShowMime
|
|
|
|
if msg.MessageInfo().Error != nil {
|
|
return fmt.Errorf("could not view message: %w", msg.MessageInfo().Error)
|
|
}
|
|
|
|
if len(msg.BodyStructure().Parts) == 0 {
|
|
switcher.selected = 0
|
|
pv, err := NewPartViewer(acct, msg, msg.BodyStructure(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switcher.parts = []*PartViewer{pv}
|
|
} else {
|
|
switcher.parts, err = enumerateParts(acct, msg,
|
|
msg.BodyStructure(), []int{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
selectedPriority := -1
|
|
log.Tracef("Selecting best message from %v", config.Viewer.Alternatives)
|
|
for i, pv := range switcher.parts {
|
|
// Switch to user's preferred mimetype
|
|
if switcher.selected == -1 && pv.part.MIMEType != "multipart" {
|
|
switcher.selected = i
|
|
}
|
|
mime := pv.part.FullMIMEType()
|
|
for idx, m := range config.Viewer.Alternatives {
|
|
if m != mime {
|
|
continue
|
|
}
|
|
priority := len(config.Viewer.Alternatives) - idx
|
|
if priority > selectedPriority {
|
|
selectedPriority = priority
|
|
switcher.selected = i
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (mv *MessageViewer) Draw(ctx *ui.Context) {
|
|
if mv.err != nil {
|
|
style := mv.acct.UiConfig().GetStyle(config.STYLE_DEFAULT)
|
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
|
|
ctx.Printf(0, 0, style, "%s", mv.err.Error())
|
|
return
|
|
}
|
|
mv.grid.Draw(ctx)
|
|
}
|
|
|
|
func (mv *MessageViewer) MouseEvent(localX int, localY int, event tcell.Event) {
|
|
if mv.err != nil {
|
|
return
|
|
}
|
|
mv.grid.MouseEvent(localX, localY, event)
|
|
}
|
|
|
|
func (mv *MessageViewer) Invalidate() {
|
|
ui.Invalidate()
|
|
}
|
|
|
|
func (mv *MessageViewer) Store() *lib.MessageStore {
|
|
return mv.msg.Store()
|
|
}
|
|
|
|
func (mv *MessageViewer) SelectedAccount() *AccountView {
|
|
return mv.acct
|
|
}
|
|
|
|
func (mv *MessageViewer) MessageView() lib.MessageView {
|
|
return mv.msg
|
|
}
|
|
|
|
func (mv *MessageViewer) SelectedMessage() (*models.MessageInfo, error) {
|
|
if mv.msg == nil {
|
|
return nil, errors.New("no message selected")
|
|
}
|
|
return mv.msg.MessageInfo(), nil
|
|
}
|
|
|
|
func (mv *MessageViewer) MarkedMessages() ([]uint32, error) {
|
|
return mv.acct.MarkedMessages()
|
|
}
|
|
|
|
func (mv *MessageViewer) ToggleHeaders() {
|
|
switcher := mv.switcher
|
|
switcher.Cleanup()
|
|
config.Viewer.ShowHeaders = !config.Viewer.ShowHeaders
|
|
err := createSwitcher(mv.acct, switcher, mv.msg)
|
|
if err != nil {
|
|
log.Errorf("cannot create switcher: %v", err)
|
|
}
|
|
switcher.Invalidate()
|
|
}
|
|
|
|
func (mv *MessageViewer) ToggleKeyPassthrough() bool {
|
|
config.Viewer.KeyPassthrough = !config.Viewer.KeyPassthrough
|
|
return config.Viewer.KeyPassthrough
|
|
}
|
|
|
|
func (mv *MessageViewer) SelectedMessagePart() *PartInfo {
|
|
switcher := mv.switcher
|
|
part := switcher.parts[switcher.selected]
|
|
|
|
return &PartInfo{
|
|
Index: part.index,
|
|
Msg: part.msg.MessageInfo(),
|
|
Part: part.part,
|
|
Links: part.links,
|
|
}
|
|
}
|
|
|
|
func (mv *MessageViewer) AttachmentParts(all bool) []*PartInfo {
|
|
var attachments []*PartInfo
|
|
|
|
for _, p := range mv.switcher.parts {
|
|
if p.part.Disposition == "attachment" || (all && p.part.FileName() != "") {
|
|
pi := &PartInfo{
|
|
Index: p.index,
|
|
Msg: p.msg.MessageInfo(),
|
|
Part: p.part,
|
|
}
|
|
attachments = append(attachments, pi)
|
|
}
|
|
}
|
|
|
|
return attachments
|
|
}
|
|
|
|
func (mv *MessageViewer) PreviousPart() {
|
|
switcher := mv.switcher
|
|
for {
|
|
switcher.selected--
|
|
if switcher.selected < 0 {
|
|
switcher.selected = len(switcher.parts) - 1
|
|
}
|
|
if switcher.parts[switcher.selected].part.MIMEType != "multipart" {
|
|
break
|
|
}
|
|
}
|
|
mv.Invalidate()
|
|
}
|
|
|
|
func (mv *MessageViewer) NextPart() {
|
|
switcher := mv.switcher
|
|
for {
|
|
switcher.selected++
|
|
if switcher.selected >= len(switcher.parts) {
|
|
switcher.selected = 0
|
|
}
|
|
if switcher.parts[switcher.selected].part.MIMEType != "multipart" {
|
|
break
|
|
}
|
|
}
|
|
mv.Invalidate()
|
|
}
|
|
|
|
func (mv *MessageViewer) Bindings() string {
|
|
if config.Viewer.KeyPassthrough {
|
|
return "view::passthrough"
|
|
} else {
|
|
return "view"
|
|
}
|
|
}
|
|
|
|
func (mv *MessageViewer) Close() {
|
|
if mv.switcher != nil {
|
|
mv.switcher.Cleanup()
|
|
}
|
|
}
|
|
|
|
func (ps *PartSwitcher) Invalidate() {
|
|
ui.Invalidate()
|
|
}
|
|
|
|
func (ps *PartSwitcher) Focus(focus bool) {
|
|
if ps.parts[ps.selected].term != nil {
|
|
ps.parts[ps.selected].term.Focus(focus)
|
|
}
|
|
}
|
|
|
|
func (ps *PartSwitcher) Show(visible bool) {
|
|
if ps.parts[ps.selected].term != nil {
|
|
ps.parts[ps.selected].term.Show(visible)
|
|
}
|
|
}
|
|
|
|
func (ps *PartSwitcher) Event(event tcell.Event) bool {
|
|
return ps.parts[ps.selected].Event(event)
|
|
}
|
|
|
|
func (ps *PartSwitcher) Draw(ctx *ui.Context) {
|
|
height := len(ps.parts)
|
|
if height == 1 && !config.Viewer.AlwaysShowMime {
|
|
ps.parts[ps.selected].Draw(ctx)
|
|
return
|
|
}
|
|
// TODO: cap height and add scrolling for messages with many parts
|
|
ps.height = ctx.Height()
|
|
y := ctx.Height() - height
|
|
for i, part := range ps.parts {
|
|
style := ps.mv.uiConfig.GetStyle(config.STYLE_DEFAULT)
|
|
if ps.selected == i {
|
|
style = ps.mv.uiConfig.GetStyleSelected(config.STYLE_DEFAULT)
|
|
}
|
|
ctx.Fill(0, y+i, ctx.Width(), 1, ' ', style)
|
|
left := len(part.index) * 2
|
|
name := formatMessagePart(part.part.FullMIMEType(), part.part.FileName(), ctx.Width()-left)
|
|
ctx.Printf(left, y+i, style, "%s", name)
|
|
}
|
|
ps.parts[ps.selected].Draw(ctx.Subcontext(
|
|
0, 0, ctx.Width(), ctx.Height()-height))
|
|
}
|
|
|
|
func (ps *PartSwitcher) MouseEvent(localX int, localY int, event tcell.Event) {
|
|
if event, ok := event.(*tcell.EventMouse); ok {
|
|
switch event.Buttons() {
|
|
case tcell.Button1:
|
|
height := len(ps.parts)
|
|
y := ps.height - height
|
|
if localY < y && ps.parts[ps.selected].term != nil {
|
|
ps.parts[ps.selected].term.MouseEvent(localX, localY, event)
|
|
}
|
|
for i := range ps.parts {
|
|
if localY != y+i {
|
|
continue
|
|
}
|
|
if ps.parts[i].part.MIMEType == "multipart" {
|
|
continue
|
|
}
|
|
if ps.parts[ps.selected].term != nil {
|
|
ps.parts[ps.selected].term.Focus(false)
|
|
}
|
|
ps.selected = i
|
|
ps.Invalidate()
|
|
if ps.parts[ps.selected].term != nil {
|
|
ps.parts[ps.selected].term.Focus(true)
|
|
}
|
|
}
|
|
case tcell.WheelDown:
|
|
height := len(ps.parts)
|
|
y := ps.height - height
|
|
if localY < y && ps.parts[ps.selected].term != nil {
|
|
ps.parts[ps.selected].term.MouseEvent(localX, localY, event)
|
|
}
|
|
if ps.parts[ps.selected].term != nil {
|
|
ps.parts[ps.selected].term.Focus(false)
|
|
}
|
|
ps.mv.NextPart()
|
|
if ps.parts[ps.selected].term != nil {
|
|
ps.parts[ps.selected].term.Focus(true)
|
|
}
|
|
case tcell.WheelUp:
|
|
height := len(ps.parts)
|
|
y := ps.height - height
|
|
if localY < y && ps.parts[ps.selected].term != nil {
|
|
ps.parts[ps.selected].term.MouseEvent(localX, localY, event)
|
|
}
|
|
if ps.parts[ps.selected].term != nil {
|
|
ps.parts[ps.selected].term.Focus(false)
|
|
}
|
|
ps.mv.PreviousPart()
|
|
if ps.parts[ps.selected].term != nil {
|
|
ps.parts[ps.selected].term.Focus(true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (ps *PartSwitcher) Cleanup() {
|
|
for _, partViewer := range ps.parts {
|
|
partViewer.Cleanup()
|
|
}
|
|
}
|
|
|
|
func formatMessagePart(mime, filename string, width int) string {
|
|
lname := runewidth.StringWidth(filename)
|
|
lmime := runewidth.StringWidth(mime)
|
|
|
|
switch {
|
|
case width <= 0:
|
|
return ""
|
|
|
|
case filename == "":
|
|
return runewidth.Truncate(mime, width, "…")
|
|
|
|
case lname+lmime+3 <= width:
|
|
// simple scenario - everything fits
|
|
return fmt.Sprintf("%s (%s)",
|
|
runewidth.FillRight(filename, width-lmime-3), mime)
|
|
|
|
case lname+3 < width:
|
|
// file name fits + we have space for parentheses and at least
|
|
// one symbol of mime
|
|
return fmt.Sprintf("%s (%s)", filename,
|
|
runewidth.Truncate(mime, width-lname-3, "…"))
|
|
|
|
default:
|
|
// ok, we don't have space even for the file name
|
|
return runewidth.Truncate(filename, width, "…")
|
|
}
|
|
}
|
|
|
|
func (mv *MessageViewer) Event(event tcell.Event) bool {
|
|
return mv.switcher.Event(event)
|
|
}
|
|
|
|
func (mv *MessageViewer) Focus(focus bool) {
|
|
mv.switcher.Focus(focus)
|
|
}
|
|
|
|
func (mv *MessageViewer) Show(visible bool) {
|
|
mv.switcher.Show(visible)
|
|
}
|
|
|
|
type PartViewer struct {
|
|
acctConfig *config.AccountConfig
|
|
err error
|
|
fetched bool
|
|
filter *exec.Cmd
|
|
index []int
|
|
msg lib.MessageView
|
|
pager *exec.Cmd
|
|
pagerin io.WriteCloser
|
|
part *models.BodyStructure
|
|
source io.Reader
|
|
term *Terminal
|
|
grid *ui.Grid
|
|
noFilter *ui.Grid
|
|
uiConfig *config.UIConfig
|
|
copying int32
|
|
|
|
links []string
|
|
}
|
|
|
|
const copying int32 = 1
|
|
|
|
func NewPartViewer(
|
|
acct *AccountView, msg lib.MessageView, part *models.BodyStructure,
|
|
curindex []int,
|
|
) (*PartViewer, error) {
|
|
var (
|
|
filter *exec.Cmd
|
|
pager *exec.Cmd
|
|
pagerin io.WriteCloser
|
|
term *Terminal
|
|
)
|
|
cmds := []string{
|
|
config.Viewer.Pager,
|
|
os.Getenv("PAGER"),
|
|
"less -Rc",
|
|
}
|
|
pagerCmd, err := acct.aerc.CmdFallbackSearch(cmds)
|
|
if err != nil {
|
|
acct.PushError(fmt.Errorf("could not start pager: %w", err))
|
|
return nil, err
|
|
}
|
|
cmd, err := shlex.Split(pagerCmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pager = exec.Command(cmd[0], cmd[1:]...)
|
|
|
|
info := msg.MessageInfo()
|
|
mime := part.FullMIMEType()
|
|
|
|
for _, f := range config.Filters {
|
|
switch f.Type {
|
|
case config.FILTER_MIMETYPE:
|
|
if fnmatch.Match(f.Filter, mime, 0) {
|
|
filter = exec.Command("sh", "-c", f.Command)
|
|
}
|
|
case config.FILTER_HEADER:
|
|
var header string
|
|
switch f.Header {
|
|
case "subject":
|
|
header = info.Envelope.Subject
|
|
case "from":
|
|
header = format.FormatAddresses(info.Envelope.From)
|
|
case "to":
|
|
header = format.FormatAddresses(info.Envelope.To)
|
|
case "cc":
|
|
header = format.FormatAddresses(info.Envelope.Cc)
|
|
default:
|
|
header = msg.MessageInfo().RFC822Headers.Get(f.Header)
|
|
}
|
|
if f.Regex.Match([]byte(header)) {
|
|
filter = exec.Command("sh", "-c", f.Command)
|
|
}
|
|
}
|
|
if filter != nil {
|
|
break
|
|
}
|
|
}
|
|
var noFilter *ui.Grid
|
|
if filter != nil {
|
|
path, _ := os.LookupEnv("PATH")
|
|
var paths []string
|
|
for _, dir := range config.SearchDirs {
|
|
paths = append(paths, dir+"/filters")
|
|
}
|
|
paths = append(paths, path)
|
|
path = strings.Join(paths, ":")
|
|
filter.Env = os.Environ()
|
|
filter.Env = append(filter.Env, fmt.Sprintf("PATH=%s", path))
|
|
filter.Env = append(filter.Env,
|
|
fmt.Sprintf("AERC_MIME_TYPE=%s", mime))
|
|
filter.Env = append(filter.Env,
|
|
fmt.Sprintf("AERC_FILENAME=%s", part.FileName()))
|
|
if flowed, ok := part.Params["format"]; ok {
|
|
filter.Env = append(filter.Env,
|
|
fmt.Sprintf("AERC_FORMAT=%s", flowed))
|
|
}
|
|
filter.Env = append(filter.Env,
|
|
fmt.Sprintf("AERC_SUBJECT=%s", info.Envelope.Subject))
|
|
filter.Env = append(filter.Env, fmt.Sprintf("AERC_FROM=%s",
|
|
format.FormatAddresses(info.Envelope.From)))
|
|
filter.Env = append(filter.Env, fmt.Sprintf("AERC_STYLESET=%s",
|
|
acct.UiConfig().StyleSetPath()))
|
|
if config.General.EnableOSC8 {
|
|
filter.Env = append(filter.Env, "AERC_OSC8_URLS=1")
|
|
}
|
|
log.Debugf("<%s> part=%v %s: %v | %v",
|
|
info.Envelope.MessageId, curindex, mime, filter, pager)
|
|
if pagerin, err = pager.StdinPipe(); err != nil {
|
|
return nil, err
|
|
}
|
|
if term, err = NewTerminal(pager); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
noFilter = newNoFilterConfigured(acct.Name(), part)
|
|
}
|
|
|
|
grid := ui.NewGrid().Rows([]ui.GridSpec{
|
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(3)}, // Message
|
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
|
}).Columns([]ui.GridSpec{
|
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
|
})
|
|
|
|
index := make([]int, len(curindex))
|
|
copy(index, curindex)
|
|
|
|
pv := &PartViewer{
|
|
acctConfig: acct.AccountConfig(),
|
|
filter: filter,
|
|
index: index,
|
|
msg: msg,
|
|
pager: pager,
|
|
pagerin: pagerin,
|
|
part: part,
|
|
term: term,
|
|
grid: grid,
|
|
noFilter: noFilter,
|
|
uiConfig: acct.UiConfig(),
|
|
}
|
|
|
|
if term != nil {
|
|
term.OnStart = func() {
|
|
pv.attemptCopy()
|
|
}
|
|
}
|
|
|
|
return pv, nil
|
|
}
|
|
|
|
func (pv *PartViewer) SetSource(reader io.Reader) {
|
|
pv.source = reader
|
|
pv.attemptCopy()
|
|
}
|
|
|
|
func (pv *PartViewer) attemptCopy() {
|
|
if pv.source == nil ||
|
|
pv.filter == nil ||
|
|
atomic.LoadInt32(&pv.copying) == copying {
|
|
return
|
|
}
|
|
atomic.StoreInt32(&pv.copying, copying)
|
|
pv.writeMailHeaders()
|
|
if strings.EqualFold(pv.part.MIMEType, "text") {
|
|
pv.source = parse.StripAnsi(pv.hyperlinks(pv.source))
|
|
}
|
|
pv.filter.Stdin = pv.source
|
|
pv.filter.Stdout = pv.pagerin
|
|
pv.filter.Stderr = pv.pagerin
|
|
err := pv.filter.Start()
|
|
if err != nil {
|
|
log.Errorf("error running filter: %v", err)
|
|
return
|
|
}
|
|
go func() {
|
|
defer log.PanicHandler()
|
|
defer atomic.StoreInt32(&pv.copying, 0)
|
|
err = pv.filter.Wait()
|
|
if err != nil {
|
|
log.Errorf("error waiting for filter: %v", err)
|
|
return
|
|
}
|
|
err = pv.pagerin.Close()
|
|
if err != nil {
|
|
log.Errorf("error closing pager pipe: %v", err)
|
|
return
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (pv *PartViewer) writeMailHeaders() {
|
|
info := pv.msg.MessageInfo()
|
|
if config.Viewer.ShowHeaders && info.RFC822Headers != nil {
|
|
var file io.WriteCloser
|
|
|
|
for _, f := range config.Filters {
|
|
if f.Type != config.FILTER_HEADERS {
|
|
continue
|
|
}
|
|
log.Debugf("<%s> piping headers in filter: %s",
|
|
info.Envelope.MessageId, f.Command)
|
|
filter := exec.Command("sh", "-c", f.Command)
|
|
if pv.filter != nil {
|
|
// inherit from filter env
|
|
filter.Env = pv.filter.Env
|
|
}
|
|
|
|
stdin, err := filter.StdinPipe()
|
|
if err == nil {
|
|
filter.Stdout = pv.pagerin
|
|
filter.Stderr = pv.pagerin
|
|
err := filter.Start()
|
|
if err == nil {
|
|
//nolint:errcheck // who cares?
|
|
defer filter.Wait()
|
|
file = stdin
|
|
} else {
|
|
log.Errorf(
|
|
"failed to start header filter: %v",
|
|
err)
|
|
}
|
|
} else {
|
|
log.Errorf("failed to create pipe: %v", err)
|
|
}
|
|
break
|
|
}
|
|
if file == nil {
|
|
file = pv.pagerin
|
|
} else {
|
|
defer file.Close()
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err := textproto.WriteHeader(&buf, info.RFC822Headers.Header.Header)
|
|
if err != nil {
|
|
log.Errorf("failed to format headers: %v", err)
|
|
}
|
|
_, err = file.Write(bytes.TrimRight(buf.Bytes(), "\r\n"))
|
|
if err != nil {
|
|
log.Errorf("failed to write headers: %v", err)
|
|
}
|
|
|
|
// virtual header
|
|
if len(info.Labels) != 0 {
|
|
labels := fmtHeader(info, "Labels", "", "", "", "")
|
|
_, err := file.Write([]byte(fmt.Sprintf("\r\nLabels: %s", labels)))
|
|
if err != nil {
|
|
log.Errorf("failed to write to labels: %v", err)
|
|
}
|
|
}
|
|
_, err = file.Write([]byte{'\r', '\n', '\r', '\n'})
|
|
if err != nil {
|
|
log.Errorf("failed to write empty line: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (pv *PartViewer) hyperlinks(r io.Reader) (reader io.Reader) {
|
|
if !config.Viewer.ParseHttpLinks {
|
|
return r
|
|
}
|
|
reader, pv.links = parse.HttpLinks(r)
|
|
return reader
|
|
}
|
|
|
|
var noFilterConfiguredCommands = [][]string{
|
|
{":open<enter>", "Open using the system handler"},
|
|
{":save<space>", "Save to file"},
|
|
{":pipe<space>", "Pipe to shell command"},
|
|
}
|
|
|
|
func newNoFilterConfigured(account string, part *models.BodyStructure) *ui.Grid {
|
|
bindings := config.Binds.MessageView.ForAccount(account)
|
|
|
|
var actions []string
|
|
|
|
configured := noFilterConfiguredCommands
|
|
if strings.Contains(strings.ToLower(part.MIMEType), "message") {
|
|
configured = append(configured, []string{
|
|
":eml<Enter>", "View message attachment",
|
|
})
|
|
}
|
|
|
|
for _, command := range configured {
|
|
cmd := command[0]
|
|
name := command[1]
|
|
strokes, _ := config.ParseKeyStrokes(cmd)
|
|
var inputs []string
|
|
for _, input := range bindings.GetReverseBindings(strokes) {
|
|
inputs = append(inputs, config.FormatKeyStrokes(input))
|
|
}
|
|
actions = append(actions, fmt.Sprintf(" %-6s %-29s %s",
|
|
strings.Join(inputs, ", "), name, cmd))
|
|
}
|
|
|
|
spec := []ui.GridSpec{
|
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(2)},
|
|
}
|
|
for i := 0; i < len(actions)-1; i++ {
|
|
spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
|
|
}
|
|
// make the last element fill remaining space
|
|
spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)})
|
|
|
|
grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{
|
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
|
})
|
|
|
|
uiConfig := config.Ui
|
|
|
|
noFilter := fmt.Sprintf(`No filter configured for this mimetype ('%s')
|
|
What would you like to do?`, part.FullMIMEType())
|
|
grid.AddChild(ui.NewText(noFilter,
|
|
uiConfig.GetStyle(config.STYLE_TITLE))).At(0, 0)
|
|
for i, action := range actions {
|
|
grid.AddChild(ui.NewText(action,
|
|
uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i+1, 0)
|
|
}
|
|
|
|
return grid
|
|
}
|
|
|
|
func (pv *PartViewer) Invalidate() {
|
|
ui.Invalidate()
|
|
}
|
|
|
|
func (pv *PartViewer) Draw(ctx *ui.Context) {
|
|
style := pv.uiConfig.GetStyle(config.STYLE_DEFAULT)
|
|
if pv.filter == nil {
|
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
|
|
pv.noFilter.Draw(ctx)
|
|
return
|
|
}
|
|
if !pv.fetched {
|
|
pv.msg.FetchBodyPart(pv.index, pv.SetSource)
|
|
pv.fetched = true
|
|
}
|
|
if pv.err != nil {
|
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
|
|
ctx.Printf(0, 0, style, "%s", pv.err.Error())
|
|
return
|
|
}
|
|
if pv.term != nil {
|
|
pv.term.Draw(ctx)
|
|
}
|
|
}
|
|
|
|
func (pv *PartViewer) Cleanup() {
|
|
if pv.term != nil {
|
|
pv.term.Close()
|
|
}
|
|
}
|
|
|
|
func (pv *PartViewer) Event(event tcell.Event) bool {
|
|
if pv.term != nil {
|
|
return pv.term.Event(event)
|
|
}
|
|
return false
|
|
}
|
|
|
|
type HeaderView struct {
|
|
Name string
|
|
Value string
|
|
ValueField ui.Drawable
|
|
uiConfig *config.UIConfig
|
|
}
|
|
|
|
func (hv *HeaderView) Draw(ctx *ui.Context) {
|
|
name := hv.Name
|
|
size := runewidth.StringWidth(name + ":")
|
|
lim := ctx.Width() - size - 1
|
|
if lim <= 0 || ctx.Height() <= 0 {
|
|
return
|
|
}
|
|
value := runewidth.Truncate(" "+hv.Value, lim, "…")
|
|
|
|
vstyle := hv.uiConfig.GetStyle(config.STYLE_DEFAULT)
|
|
hstyle := hv.uiConfig.GetStyle(config.STYLE_HEADER)
|
|
|
|
// TODO: Make this more robust and less dumb
|
|
if hv.Name == "PGP" {
|
|
vstyle = hv.uiConfig.GetStyle(config.STYLE_SUCCESS)
|
|
}
|
|
|
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', vstyle)
|
|
ctx.Printf(0, 0, hstyle, "%s:", name)
|
|
if hv.ValueField == nil {
|
|
ctx.Printf(size, 0, vstyle, "%s", value)
|
|
} else {
|
|
hv.ValueField.Draw(ctx.Subcontext(size, 0, lim, 1))
|
|
}
|
|
}
|
|
|
|
func (hv *HeaderView) Invalidate() {
|
|
ui.Invalidate()
|
|
}
|