init: pristine aerc 0.20.0 source
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
image: alpine/edge
|
||||||
|
packages:
|
||||||
|
- curl
|
||||||
|
- go
|
||||||
|
- gnupg
|
||||||
|
- notmuch-dev
|
||||||
|
- py3-codespell
|
||||||
|
- scdoc
|
||||||
|
- valgrind
|
||||||
|
sources:
|
||||||
|
- "https://git.sr.ht/~rjarry/aerc"
|
||||||
|
environment:
|
||||||
|
DESTDIR: ./out
|
||||||
|
GOFLAGS: "-tags=notmuch"
|
||||||
|
CC: gcc
|
||||||
|
FILTERS_TEST_BIN_PREFIX: valgrind --leak-check=full --error-exitcode=1
|
||||||
|
tasks:
|
||||||
|
- validate: |
|
||||||
|
gmake -C aerc validate
|
||||||
|
- install: |
|
||||||
|
gmake -C aerc install checkinstall
|
||||||
|
- ancient-go-version: |
|
||||||
|
curl -O https://dl-cdn.alpinelinux.org/alpine/v3.19/community/x86_64/go-1.21.10-r0.apk
|
||||||
|
sudo apk add ./go-1.21.10-r0.apk
|
||||||
|
gmake -C aerc clean all
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
image: openbsd/latest
|
||||||
|
packages:
|
||||||
|
- base64
|
||||||
|
- gmake
|
||||||
|
- gnupg
|
||||||
|
- go
|
||||||
|
- scdoc
|
||||||
|
sources:
|
||||||
|
- "https://git.sr.ht/~rjarry/aerc"
|
||||||
|
environment:
|
||||||
|
DESTDIR: ./out
|
||||||
|
tasks:
|
||||||
|
- build: |
|
||||||
|
gmake -C aerc
|
||||||
|
- install: |
|
||||||
|
gmake -C aerc install checkinstall
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# ex: ft=dosini
|
||||||
|
|
||||||
|
[codespell]
|
||||||
|
quiet-level = 35
|
||||||
|
skip =
|
||||||
|
*.1,
|
||||||
|
*.5,
|
||||||
|
*.7,
|
||||||
|
*log*,
|
||||||
|
*.log*,
|
||||||
|
.changelog.md,
|
||||||
|
.env,
|
||||||
|
contrib/aerc.desktop,
|
||||||
|
filters/vectors/*,
|
||||||
|
tags,
|
||||||
|
ignore-words-list =
|
||||||
|
DeVault,
|
||||||
|
Fo,
|
||||||
|
THRID,
|
||||||
|
fo,
|
||||||
|
fpr,
|
||||||
|
froms,
|
||||||
|
localY,
|
||||||
|
ment,
|
||||||
|
struc,
|
||||||
|
te,
|
||||||
|
thrid,
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# https://editorconfig.org/
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
charset = utf-8
|
||||||
|
|
||||||
|
[**.go]
|
||||||
|
indent_style = tab
|
||||||
|
max_line_length = 80
|
||||||
|
tab_width = 8
|
||||||
|
|
||||||
|
[*Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[**.scd]
|
||||||
|
indent_style = tab
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
go:
|
||||||
|
- '1.21'
|
||||||
|
- '1.22'
|
||||||
|
env:
|
||||||
|
DESTDIR: ./out
|
||||||
|
GOFLAGS: -tags=notmuch
|
||||||
|
name: MacOS Go ${{ matrix.go }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ matrix.go }}
|
||||||
|
check-latest: true
|
||||||
|
- run: brew install gnupg notmuch scdoc
|
||||||
|
- run: |
|
||||||
|
cat >> "$GITHUB_ENV" <<EOF
|
||||||
|
CGO_CFLAGS=-I$(brew --prefix)/include
|
||||||
|
CGO_LDFLAGS=-L$(brew --prefix)/lib -Wl,-rpath,$(brew --prefix)/lib
|
||||||
|
EOF
|
||||||
|
- run: make
|
||||||
|
- run: make install
|
||||||
|
- run: make checkinstall
|
||||||
|
- run: make tests
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
/aerc
|
||||||
|
/aerc.debug
|
||||||
|
/wrap
|
||||||
|
/colorize
|
||||||
|
/linters.so
|
||||||
|
/*log*
|
||||||
|
/*.log*
|
||||||
|
/*.1
|
||||||
|
/*.5
|
||||||
|
/*.7
|
||||||
|
/.env
|
||||||
|
/.changelog.md
|
||||||
|
/aerc-release-stats.png
|
||||||
|
/tags
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[run]
|
||||||
|
# don't lint tests
|
||||||
|
tests = false
|
||||||
|
|
||||||
|
# enable additional linters
|
||||||
|
[linters]
|
||||||
|
enable = [
|
||||||
|
"nolintlint", # nolint comments require justification
|
||||||
|
"errorlint", # check to ensure no problems with wrapped errors
|
||||||
|
"gocritic", # check for bugs, performance, and style issues
|
||||||
|
"gofmt", # check that gofmt is satisfied
|
||||||
|
]
|
||||||
|
|
||||||
|
[linters-settings.nolintlint]
|
||||||
|
allow-unused = false # don't allow nolint if not required
|
||||||
|
require-explanation = true # require an explanation when disabling a linter
|
||||||
|
requre-specific = true # linter exceptions must specify the linter
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
Aditya Srivastava <adityasri163@gmail.com> <adityasri@ocf.berkeley.edu>
|
||||||
|
Andrew Jeffrey <dev@jeffas.io> <andrewjeffery97@gmail.com>
|
||||||
|
Andrew Jeffrey <dev@jeffas.io> Andrew Jeffery <dev@jeffas.io>
|
||||||
|
Andrew Jeffrey <dev@jeffas.io> Jeffas <dev@jeffas.io>
|
||||||
|
Bor Grošelj Simić <bgs@turminal.net> <bor.groseljsimic@telemach.net>
|
||||||
|
Christopher Vittal <chris@vittal.dev> <christopher.vittal@gmail.com>
|
||||||
|
Christopher Vittal <chris@vittal.dev> Chris Vittal <chris@vittal.dev>
|
||||||
|
Drew DeVault <sir@cmpwn.com> <ddevault@vistarmedia.com>
|
||||||
|
Inwit <inwit@sindominio.net>
|
||||||
|
JD <john1doe@ya.ru>
|
||||||
|
Kalyan Sriram <kalyan@coderkalyan.com> <coder.kalyan@gmail.com>
|
||||||
|
Kevin Kuehler <kkuehler@brave.com> <keur@ocf.berkeley.edu>
|
||||||
|
Kevin Kuehler <kkuehler@brave.com> <keur@xcf.berkeley.edu>
|
||||||
|
Leszek Cimała <ernierasta@zori.cz>
|
||||||
|
Moritz Poldrack <moritz@poldrack.dev> <git@moritz.sh>
|
||||||
|
Peter Lamby <dev@peterlamby.de> <Peter.Lamby@direkt-gruppe.de>
|
||||||
|
Simon Ser <contact@emersion.fr>
|
||||||
|
Thomas Böhler <witcher@wiredspace.de>
|
||||||
|
Tim Culverhouse <tim@timculverhouse.com> <tim@tim.culverhouse.com>
|
||||||
|
Wagner Riffel <wgrriffel@gmail.com> <w@104d.net>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
debian/patches
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
series
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
2
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fix-blhc.patch
|
||||||
|
fix-temp-file-creation.patch
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# variables that can be changed by users
|
||||||
|
#
|
||||||
|
VERSION ?= $(shell git describe --long --abbrev=12 --tags --dirty 2>/dev/null || echo 0.20.0)
|
||||||
|
DATE ?= $(shell date +%Y-%m-%d)
|
||||||
|
PREFIX ?= /usr/local
|
||||||
|
BINDIR ?= $(PREFIX)/bin
|
||||||
|
SHAREDIR ?= $(PREFIX)/share/aerc
|
||||||
|
LIBEXECDIR ?= $(PREFIX)/libexec/aerc
|
||||||
|
MANDIR ?= $(PREFIX)/share/man
|
||||||
|
GO ?= go
|
||||||
|
INSTALL ?= install
|
||||||
|
CP ?= cp
|
||||||
|
GOFLAGS ?= $(shell contrib/goflags.sh)
|
||||||
|
BUILD_OPTS ?= -trimpath
|
||||||
|
GO_LDFLAGS :=
|
||||||
|
GO_LDFLAGS += -X main.Version=$(VERSION)
|
||||||
|
GO_LDFLAGS += -X main.Date=$(DATE)
|
||||||
|
GO_LDFLAGS += -X git.sr.ht/~rjarry/aerc/config.shareDir=$(SHAREDIR)
|
||||||
|
GO_LDFLAGS += -X git.sr.ht/~rjarry/aerc/config.libexecDir=$(LIBEXECDIR)
|
||||||
|
GO_LDFLAGS += $(GO_EXTRA_LDFLAGS)
|
||||||
|
CC ?= cc
|
||||||
|
CFLAGS ?= -O2 -g
|
||||||
|
|
||||||
|
# internal variables used for automatic rules generation with macros
|
||||||
|
gosrc = $(shell find * -type f -name '*.go') go.mod go.sum
|
||||||
|
man1 = $(subst .scd,,$(notdir $(wildcard doc/*.1.scd)))
|
||||||
|
man5 = $(subst .scd,,$(notdir $(wildcard doc/*.5.scd)))
|
||||||
|
man7 = $(subst .scd,,$(notdir $(wildcard doc/*.7.scd)))
|
||||||
|
docs = $(man1) $(man5) $(man7)
|
||||||
|
cfilters = $(subst .c,,$(notdir $(wildcard filters/*.c)))
|
||||||
|
filters = $(filter-out filters/vectors filters/test.sh filters/%.c,$(wildcard filters/*))
|
||||||
|
gofumpt_tag = v0.7.0
|
||||||
|
|
||||||
|
# Dependencies are added dynamically to the "all" rule with macros
|
||||||
|
.PHONY: all
|
||||||
|
all: aerc
|
||||||
|
@:
|
||||||
|
|
||||||
|
aerc: $(gosrc)
|
||||||
|
$(GO) build $(BUILD_OPTS) $(GOFLAGS) -ldflags "$(GO_LDFLAGS)" -o aerc
|
||||||
|
|
||||||
|
.PHONY: dev
|
||||||
|
dev:
|
||||||
|
$(RM) aerc
|
||||||
|
$(MAKE) --no-print-directory aerc BUILD_OPTS="-trimpath -race"
|
||||||
|
GORACE="log_path=race.log strip_path_prefix=git.sr.ht/~rjarry/aerc/" ./aerc
|
||||||
|
|
||||||
|
.PHONY: fmt
|
||||||
|
fmt:
|
||||||
|
$(GO) run mvdan.cc/gofumpt@$(gofumpt_tag) -w .
|
||||||
|
|
||||||
|
.PHONY: lint
|
||||||
|
lint:
|
||||||
|
@contrib/check-whitespace `git ls-files ':!:filters/vectors'` && \
|
||||||
|
echo white space ok.
|
||||||
|
@contrib/check-docs && echo docs ok.
|
||||||
|
@$(GO) run mvdan.cc/gofumpt@$(gofumpt_tag) -d . | grep ^ \
|
||||||
|
&& echo The above files need to be formatted, please run make fmt && exit 1 \
|
||||||
|
|| echo all files formatted.
|
||||||
|
codespell *
|
||||||
|
$(GO) run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2 run \
|
||||||
|
$$(echo $(GOFLAGS) | sed s/-tags=/--build-tags=/)
|
||||||
|
$(GO) run $(GOFLAGS) contrib/linters.go ./...
|
||||||
|
|
||||||
|
.PHONY: vulncheck
|
||||||
|
vulncheck:
|
||||||
|
$(GO) run golang.org/x/vuln/cmd/govulncheck@latest ./...
|
||||||
|
|
||||||
|
.PHONY: tests
|
||||||
|
tests: $(cfilters)
|
||||||
|
$(GO) test $(GOFLAGS) ./...
|
||||||
|
filters/test.sh
|
||||||
|
|
||||||
|
.PHONY: debug
|
||||||
|
debug: aerc.debug
|
||||||
|
@echo 'Run `./aerc.debug` and use this command in another terminal to attach a debugger:'
|
||||||
|
@echo ' dlv attach $$(pidof aerc.debug)'
|
||||||
|
|
||||||
|
aerc.debug: $(gosrc)
|
||||||
|
$(GO) build $(subst -trimpath,,$(GOFLAGS)) -gcflags=all="-N -l" -ldflags="$(GO_LDFLAGS)" -o aerc.debug
|
||||||
|
|
||||||
|
.PHONY: doc
|
||||||
|
doc: $(docs)
|
||||||
|
@:
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
$(RM) $(docs) aerc $(cfilters)
|
||||||
|
|
||||||
|
# Dependencies are added dynamically to the "install" rule with macros
|
||||||
|
.PHONY: install
|
||||||
|
install:
|
||||||
|
@:
|
||||||
|
|
||||||
|
.PHONY: checkinstall
|
||||||
|
checkinstall:
|
||||||
|
$(DESTDIR)$(BINDIR)/aerc -v
|
||||||
|
for m in $(man1); do test -e $(DESTDIR)$(MANDIR)/man1/$$m || exit; done
|
||||||
|
for m in $(man5); do test -e $(DESTDIR)$(MANDIR)/man5/$$m || exit; done
|
||||||
|
for m in $(man7); do test -e $(DESTDIR)$(MANDIR)/man7/$$m || exit; done
|
||||||
|
|
||||||
|
.PHONY: uninstall
|
||||||
|
uninstall:
|
||||||
|
@echo $(installed) | tr ' ' '\n' | sort -ru | while read -r f; do \
|
||||||
|
echo rm -f $$f && rm -f $$f || exit; \
|
||||||
|
done
|
||||||
|
@echo $(dirs) | tr ' ' '\n' | sort -ru | while read -r d; do \
|
||||||
|
if [ -d $$d ] && ! ls -Aq1 $$d | grep -q .; then \
|
||||||
|
echo rmdir $$d && rmdir $$d || exit; \
|
||||||
|
fi; \
|
||||||
|
done
|
||||||
|
|
||||||
|
.PHONY: gitconfig
|
||||||
|
gitconfig:
|
||||||
|
git config format.subjectPrefix "PATCH aerc"
|
||||||
|
git config sendemail.to "~rjarry/aerc-devel@lists.sr.ht"
|
||||||
|
git config format.notes true
|
||||||
|
git config notes.rewriteRef refs/notes/commits
|
||||||
|
git config notes.rewriteMode concatenate
|
||||||
|
@mkdir -p .git/hooks
|
||||||
|
@rm -f .git/hooks/commit-msg*
|
||||||
|
ln -s ../../contrib/commit-msg .git/hooks/commit-msg
|
||||||
|
@rm -f .git/hooks/sendemail-validate*
|
||||||
|
@if grep -q GIT_SENDEMAIL_FILE_COUNTER `git --exec-path`/git-send-email 2>/dev/null; then \
|
||||||
|
set -xe; \
|
||||||
|
ln -s ../../contrib/sendemail-validate .git/hooks/sendemail-validate && \
|
||||||
|
git config sendemail.validate true; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
.PHONY: check-patches
|
||||||
|
check-patches:
|
||||||
|
@contrib/check-patches origin/master..
|
||||||
|
|
||||||
|
.PHONY: validate
|
||||||
|
validate: CFLAGS = -Wall -Wextra -Wconversion -Werror -Wformat-security -Wstack-protector -Wpedantic -Wmissing-prototypes
|
||||||
|
validate: all tests lint check-patches
|
||||||
|
|
||||||
|
# Generate build and install rules for one man page
|
||||||
|
#
|
||||||
|
# $1: man page name (e.g: aerc.1)
|
||||||
|
#
|
||||||
|
define install_man
|
||||||
|
$1: doc/$1.scd
|
||||||
|
scdoc < $$< > $$@
|
||||||
|
|
||||||
|
$1_section = $$(subst .,,$$(suffix $1))
|
||||||
|
$1_install_dir = $$(DESTDIR)$$(MANDIR)/man$$($1_section)
|
||||||
|
dirs += $$($1_install_dir)
|
||||||
|
installed += $$($1_install_dir)/$1
|
||||||
|
|
||||||
|
$$($1_install_dir)/$1: $1 | $$($1_install_dir)
|
||||||
|
$$(INSTALL) -m644 $$< $$@
|
||||||
|
|
||||||
|
all: $1
|
||||||
|
install: $$($1_install_dir)/$1
|
||||||
|
endef
|
||||||
|
|
||||||
|
# Generate build and install rules for one filter
|
||||||
|
#
|
||||||
|
# $1: filter source path or name
|
||||||
|
#
|
||||||
|
define install_filter
|
||||||
|
ifneq ($(wildcard filters/$1.c),)
|
||||||
|
$1: filters/$1.c
|
||||||
|
$$(CC) $$(CFLAGS) $$(LDFLAGS) -o $$@ $$<
|
||||||
|
|
||||||
|
all: $1
|
||||||
|
endif
|
||||||
|
|
||||||
|
$1_install_dir = $$(DESTDIR)$$(LIBEXECDIR)/filters
|
||||||
|
dirs += $$($1_install_dir)
|
||||||
|
installed += $$($1_install_dir)/$$(notdir $1)
|
||||||
|
|
||||||
|
$$($1_install_dir)/$$(notdir $1): $1 | $$($1_install_dir)
|
||||||
|
$$(CP) -af $$< $$@
|
||||||
|
|
||||||
|
install: $$($1_install_dir)/$$(notdir $1)
|
||||||
|
endef
|
||||||
|
|
||||||
|
# Generate install rules for any file
|
||||||
|
#
|
||||||
|
# $1: source file
|
||||||
|
# $2: mode
|
||||||
|
# $3: target dir
|
||||||
|
#
|
||||||
|
define install_file
|
||||||
|
dirs += $3
|
||||||
|
installed += $3/$$(notdir $1)
|
||||||
|
|
||||||
|
$3/$$(notdir $1): $1 | $3
|
||||||
|
$$(INSTALL) -m$2 $$< $$@
|
||||||
|
|
||||||
|
install: $3/$$(notdir $1)
|
||||||
|
endef
|
||||||
|
|
||||||
|
# Call macros to generate build and install rules
|
||||||
|
$(foreach m,$(docs),\
|
||||||
|
$(eval $(call install_man,$m)))
|
||||||
|
$(foreach f,$(filters) $(cfilters),\
|
||||||
|
$(eval $(call install_filter,$f)))
|
||||||
|
$(foreach f,$(wildcard config/*.conf),\
|
||||||
|
$(eval $(call install_file,$f,644,$(DESTDIR)$(SHAREDIR))))
|
||||||
|
$(foreach s,$(wildcard stylesets/*),\
|
||||||
|
$(eval $(call install_file,$s,644,$(DESTDIR)$(SHAREDIR)/stylesets)))
|
||||||
|
$(foreach t,$(wildcard templates/*),\
|
||||||
|
$(eval $(call install_file,$t,644,$(DESTDIR)$(SHAREDIR)/templates)))
|
||||||
|
$(eval $(call install_file,contrib/aerc.desktop,644,$(DESTDIR)$(PREFIX)/share/applications))
|
||||||
|
$(eval $(call install_file,aerc,755,$(DESTDIR)$(BINDIR)))
|
||||||
|
$(eval $(call install_file,contrib/carddav-query,755,$(DESTDIR)$(BINDIR)))
|
||||||
|
|
||||||
|
$(sort $(dirs)):
|
||||||
|
mkdir -p $@
|
||||||
|
|
||||||
|
.DELETE_ON_ERROR:
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package msgview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Open struct {
|
||||||
|
Delete bool `opt:"-d" desc:"Delete temp file after the opener exits."`
|
||||||
|
Cmd string `opt:"..." required:"false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Open{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Open) Description() string {
|
||||||
|
return "Save the current message part to a temporary file, then open it."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Open) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_VIEWER
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Open) Aliases() []string {
|
||||||
|
return []string{"open"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Open) Execute(args []string) error {
|
||||||
|
mv := app.SelectedTabContent().(*app.MessageViewer)
|
||||||
|
if mv == nil {
|
||||||
|
return errors.New("open only supported selected message parts")
|
||||||
|
}
|
||||||
|
p := mv.SelectedMessagePart()
|
||||||
|
|
||||||
|
mv.MessageView().FetchBodyPart(p.Index, func(reader io.Reader) {
|
||||||
|
mimeType := ""
|
||||||
|
|
||||||
|
part, err := mv.MessageView().BodyStructure().PartAtIndex(p.Index)
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mimeType = part.FullMIMEType()
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp(os.TempDir(), "aerc-*")
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filename := part.FileName()
|
||||||
|
var tmpFile *os.File
|
||||||
|
if filename == "" {
|
||||||
|
extension := ""
|
||||||
|
if exts, _ := mime.ExtensionsByType(mimeType); len(exts) > 0 {
|
||||||
|
extension = exts[0]
|
||||||
|
}
|
||||||
|
tmpFile, err = os.CreateTemp(tmpDir, "aerc-*"+extension)
|
||||||
|
} else {
|
||||||
|
tmpFile, err = os.Create(filepath.Join(tmpDir, filename))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(tmpFile, reader)
|
||||||
|
tmpFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
if o.Delete {
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
}
|
||||||
|
err = lib.XDGOpenMime(tmpFile.Name(), mimeType, o.Cmd)
|
||||||
|
if err != nil {
|
||||||
|
app.PushError("open: " + err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+764
@@ -0,0 +1,764 @@
|
|||||||
|
# Change Log
|
||||||
|
|
||||||
|
All notable changes to aerc will be documented in this file.
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
## [0.20.0](https://git.sr.ht/~rjarry/aerc/refs/0.20.0) - 2025-01-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `copy-to` now supports multiple destination folders.
|
||||||
|
- All commands that involve composing messages (`:compose`, `:reply`,
|
||||||
|
`:recall`, `:unsubscribe` and `:forward`) now have a new `-s` flag to skip
|
||||||
|
opening the text editor and go directly to the review screen. Previously,
|
||||||
|
this flag was restricted to calendar invitations response commands
|
||||||
|
(`:accept`, `:accept-tentative` and `:decline`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `copy-to-replied` now properly works without having `copy-to` also set.
|
||||||
|
- `copy-to-replied` creates empty messages when `copy-to` is also set.
|
||||||
|
- The address-book completion popovers now again appear under the field being
|
||||||
|
completed.
|
||||||
|
- The new-message bell is now rung again for threaded directories as well.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The `default` styleset status line background has been reset to the default
|
||||||
|
color (light or dark, depending on your terminal color scheme) in order to
|
||||||
|
make error, warning or success messages more readable.
|
||||||
|
- Key bindings in the compose review screen are now displayed in the order in
|
||||||
|
which they are defined in the `[compose::review]` section of `binds.conf`.
|
||||||
|
- It is now possible to explicitly hide key bindings from the compose review
|
||||||
|
screen by using a special ` # -` annotation.
|
||||||
|
|
||||||
|
### Closed Tickets
|
||||||
|
|
||||||
|
- [#296: :compose: add flag to go directly to review screen](https://todo.sr.ht/~rjarry/aerc/296)
|
||||||
|
|
||||||
|
## [0.19.0](https://git.sr.ht/~rjarry/aerc/refs/0.19.0) - 2025-01-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- New `:redraw` command to force a repaint of the screen.
|
||||||
|
- New `head` and `tail` templates functions for strings.
|
||||||
|
- New `{{.AccountFrom}}` template variable.
|
||||||
|
- Replying to all will include the Sender in Cc.
|
||||||
|
- Add `-b` flag to the `:view` command to open messages in a background tab.
|
||||||
|
- `AERC_ACCOUNT` and `AERC_FOLDER` are now available in the signature command
|
||||||
|
environment.
|
||||||
|
- Filters will receive the actual `COLUMNS` and `LINES` values.
|
||||||
|
- The `:forward` command now sets the forwarded flag.
|
||||||
|
- Forwarded messages can now be searched for and filtered in notmuch and
|
||||||
|
maildir.
|
||||||
|
- Forwarded messages can be styled differently in the message list.
|
||||||
|
- Forwarded messages can be identified with the `{{.IsForwarded}}` template.
|
||||||
|
- The `:flag` command now sets/unsets/toggle the forwarded tag.
|
||||||
|
- The notmuch backend now honors the forwarded flag, setting the `passed` tag.
|
||||||
|
- The maildir backend now honors the `forwarded`/`passed` flag.
|
||||||
|
- Auto-switch projects based on the message subject for the `:patch` command.
|
||||||
|
- New `:echo` command that prints its arguments with templates resolved.
|
||||||
|
- New `use-envelope-from` option in `accounts.conf`.
|
||||||
|
- Command completion now displays descriptions next to completion items.
|
||||||
|
- New `completion_description` style object in style sets used for rendering
|
||||||
|
completion item descriptions.
|
||||||
|
- `:import-mbox` can now import data from an URL.
|
||||||
|
- Dynamic message list style can now match on multiple email headers.
|
||||||
|
- The JMAP backend now supports full thread fetching and caching (limited
|
||||||
|
within a single mailbox).
|
||||||
|
- `:expand-folder` and `:collapse-folder` can now act on a non selected folder.
|
||||||
|
- Filters commands can now provide their own paging by prefixing them with a
|
||||||
|
`!` character. Doing so will disable the configured `[viewer].pager` and
|
||||||
|
connect them directly to the terminal.
|
||||||
|
- Reply to addresses in `From` and `Reply-To` headers with `:reply -f`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Builtin `calendar` filter shows empty attendee list.
|
||||||
|
- Terminal-based pinentry programs (e.g. `pinentry-curses`) now work properly.
|
||||||
|
- Failure to create IPC socket on Gentoo.
|
||||||
|
- Notmuch searches which explicitly contain tags from `exclude-tags` now return
|
||||||
|
messages.
|
||||||
|
- Invitations now honor the `:send -a` flag.
|
||||||
|
- Remove unwanted less than symbol from In-Reply-To header when Message-ID uses
|
||||||
|
folding.
|
||||||
|
- Aliases are now taken into account correctly when replying to own messages
|
||||||
|
such as from the Sent folder or via a mailing list.
|
||||||
|
- Some SMTP servers do not strip `Bcc` headers. aerc now removes them before
|
||||||
|
sending emails to avoid leaking private information. A new `strip-bcc =
|
||||||
|
false` option can be used in `accounts.conf` to revert to previous behaviour
|
||||||
|
(preserve `Bcc` headers in outgoing messages).
|
||||||
|
- There should no longer be any duplicates in recipient lists when replying.
|
||||||
|
- GPG signatures and encrypted parts now use CRLF line endings as required by
|
||||||
|
RFC 5322.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Template function `quote` only prefixes with a space if at quote depth `1`.
|
||||||
|
- Templates passed to the `:reply` command using the `-T` flag can now make use
|
||||||
|
of `{{.OriginalText}}`.
|
||||||
|
- The location of the command history file has changed to
|
||||||
|
`${XDG_STATE_HOME:-$HOME/.local/state}/aerc/history`.
|
||||||
|
- Tab completions for text fields are run asynchronously. In-flight requests
|
||||||
|
are cancelled when new input arrives.
|
||||||
|
- Path completion now uses the normal filtering mechanism, respecting case
|
||||||
|
sensitivity and the fuzzy completion option.
|
||||||
|
- The `html` filter is now enabled by default, making `w3m` a weak runtime
|
||||||
|
dependency. If it is not installed, viewing HTML emails will fail with an
|
||||||
|
explicit error.
|
||||||
|
- The default `text/html` filter will now run `w3m` in interactive mode.
|
||||||
|
- The builtin `html` and `html-unsafe` filters can now take additional
|
||||||
|
arguments that will be passed to `w3m`. This can be used to enable inline
|
||||||
|
images when viewing `text/html` parts (e.g.: `text/html = ! html-unsafe
|
||||||
|
-sixel`).
|
||||||
|
- The templates `exec` commands is now executed with the `filters` exec `$PATH`
|
||||||
|
similar to filter commands.
|
||||||
|
- The default `quoted_reply` template now converts `text/html` parts to plain
|
||||||
|
text before quoting them.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- Support for go 1.20 and older.
|
||||||
|
|
||||||
|
### Closed Tickets
|
||||||
|
|
||||||
|
- [#150: Expand .Account with .Address and .Name](https://todo.sr.ht/~rjarry/aerc/150)
|
||||||
|
- [#202: pinentry-tty breaks aerc](https://todo.sr.ht/~rjarry/aerc/202)
|
||||||
|
- [#215: regression since bumping go-maildir](https://todo.sr.ht/~rjarry/aerc/215)
|
||||||
|
- [#220: add trim to templates](https://todo.sr.ht/~rjarry/aerc/220)
|
||||||
|
- [#226: Automatic patch switch based on email prefix](https://todo.sr.ht/~rjarry/aerc/226)
|
||||||
|
- [#232: $(tput cols) in filters report 80 all the time](https://todo.sr.ht/~rjarry/aerc/232)
|
||||||
|
- [#238: Implement decryption on action {cp,mv,pipe}](https://todo.sr.ht/~rjarry/aerc/238)
|
||||||
|
- [#250: allow disabling pager in filter](https://todo.sr.ht/~rjarry/aerc/250)
|
||||||
|
- [#259: :reply -a should reply to Sender as well](https://todo.sr.ht/~rjarry/aerc/259)
|
||||||
|
- [#266: Add opening individual emails in the background](https://todo.sr.ht/~rjarry/aerc/266)
|
||||||
|
- [#271: Add documentation to options in the autocomplete menu](https://todo.sr.ht/~rjarry/aerc/271)
|
||||||
|
- [#277: add :echo command](https://todo.sr.ht/~rjarry/aerc/277)
|
||||||
|
- [#281: Unable to open local `mbox` files](https://todo.sr.ht/~rjarry/aerc/281)
|
||||||
|
- [#283: BCC headers are exposed to recipients with gmail](https://todo.sr.ht/~rjarry/aerc/283)
|
||||||
|
- [#287: Crash when running :pipe -m less](https://todo.sr.ht/~rjarry/aerc/287)
|
||||||
|
- [#288: "could not MessageInfo ... NextPart: EOF" on a specific email](https://todo.sr.ht/~rjarry/aerc/288)
|
||||||
|
- [#294: Sender is not decoded in message view](https://todo.sr.ht/~rjarry/aerc/294)
|
||||||
|
|
||||||
|
## [0.18.2](https://git.sr.ht/~rjarry/aerc/refs/0.18.2) - 2024-07-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Builtin `calendar` filter error with non-GNU Awk.
|
||||||
|
- Detection of unicode width measurements on tmux 3.4.
|
||||||
|
- Dropping of events during large pastes.
|
||||||
|
- Home and End key decoding for the st terminal.
|
||||||
|
|
||||||
|
## [0.18.1](https://git.sr.ht/~rjarry/aerc/refs/0.18.1) - 2024-07-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Startup error if `log-file` directory does not exist.
|
||||||
|
- Aerc is now less pedantic about invalid headers for the maildir and notmuch
|
||||||
|
backends.
|
||||||
|
- Error when trying to configure `smtp-domain` with STARTTLS enabled.
|
||||||
|
- `smtp-domain` is now properly taken into account for TLS connections.
|
||||||
|
|
||||||
|
## [0.18.0](https://git.sr.ht/~rjarry/aerc/refs/0.18.0) - 2024-07-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add `[ui].msglist-scroll-offset` option to set a scroll offset for the
|
||||||
|
message list.
|
||||||
|
- Add new `:align` command to align the selected message at the top, center, or
|
||||||
|
bottom of the message list.
|
||||||
|
- Inline image previews when no filter is defined for `image/*` and the
|
||||||
|
terminal supports it.
|
||||||
|
- `:bounce` command to reintroduce messages into the transport system.
|
||||||
|
- Message counts are available in statusline templates.
|
||||||
|
- Execute IPC commands verbatim by providing the command and its args as a
|
||||||
|
single argument in the shell.
|
||||||
|
- Virtually any key binding can now be configured in `binds.conf`, including
|
||||||
|
Shift+Alt+Control modifier combinations.
|
||||||
|
- Configure default message list `:split` or `:vsplit` on startup with
|
||||||
|
`message-list-split` in `aerc.conf`.
|
||||||
|
- Create notmuch named queries with the `:query` command.
|
||||||
|
- Specify a ":q" alias for quit.
|
||||||
|
- The `:detach` command now understands globs similar to `:attach`.
|
||||||
|
- Match filters on filename via `.filename,~<regexp> =`.
|
||||||
|
- Tell aerc how to handle file-based operations on multi-file notmuch messages
|
||||||
|
with the account config option `multi-file-strategy` and the `-m` flag to
|
||||||
|
`:archive`, `:copy`, `:delete`, and `:move`.
|
||||||
|
- Add `[ui].dialog-{position,width,height}` to set the position, width and
|
||||||
|
height of popover dialogs.
|
||||||
|
- New `pgp-self-encrypt` option in `accounts.conf`.
|
||||||
|
- Add `--no-ipc` flag to run `aerc mailto:...`, `aerc mbox:...`, and `aerc
|
||||||
|
:<command...>` within the current aerc instance and prevent listening for IPC
|
||||||
|
calls from other aerc instances.
|
||||||
|
- Add config options `disable-ipc-mailto` and `disable-ipc-mbox` to make
|
||||||
|
`mailto:...` and `mbox:...` commands always run in a new aerc instance.
|
||||||
|
- Set global options in `accounts.conf` by placing them at the top of the file.
|
||||||
|
- Silently close the terminal tab after piping a message to a command with
|
||||||
|
`:pipe -s <cmd>`.
|
||||||
|
- New `tag-modified` hook for notmuch and JMAP accounts.
|
||||||
|
- New `flag-changed` hook.
|
||||||
|
- Notmuch search term completions to `:query`.
|
||||||
|
- Notmuch completions for `:cf`, `:filter` and `:search`.
|
||||||
|
- Add `imaps+insecure` to the available protocols, for connections that should
|
||||||
|
ignore issues with certificate verification.
|
||||||
|
- Add `[ui].select-last-message` option to position cursor at the bottom of the
|
||||||
|
view.
|
||||||
|
- Propagate terminal bell from the built-in terminal.
|
||||||
|
- Added `AERC_FOLDER_ROLE` to hooks that have `AERC_FOLDER`.
|
||||||
|
- Added `{{.AccountBackend}}` to templates.
|
||||||
|
- Added `AERC_ACCOUNT_BACKEND` to hooks with `AERC_ACCOUNT`.
|
||||||
|
- Per folder key bindings can now be defined for the message viewer.
|
||||||
|
- Allow using existing directory name with `:query -f`.
|
||||||
|
- Allow specifying the folder to delete with `:rmdir`.
|
||||||
|
- The address book is now used for `:cc`, `:bcc` and `:forward`.
|
||||||
|
- Allow fallback to threading by subject with `[ui].threading-by-subject`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Calendar responses now ignore case.
|
||||||
|
- Allow account- and folder-specific binds to coexist.
|
||||||
|
- Fixed crash when running `:send` with a `:preview` tab focused.
|
||||||
|
- Deadlock when running `aerc mailto:foo@bar.com` without another instance of
|
||||||
|
aerc already running.
|
||||||
|
- Prevent a freeze for large-scale deletions with IMAP.
|
||||||
|
- `Mime-Version` is no longer inserted in signed text parts headers. MTAs
|
||||||
|
normalizing header case will not corrupt signatures anymore.
|
||||||
|
- Restore previous behaviour of the new message bell which was broken in the
|
||||||
|
last two releases for at least some setups.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The default `[ui]` settings and the `default` styleset have changed
|
||||||
|
extensively. A no-color theme can be restored with the `monochrome` styleset.
|
||||||
|
- The default `colorize` theme has been changed to use the base terminal colors.
|
||||||
|
- The `[viewer]` section of stylesets now preserve default values as documented
|
||||||
|
in `aerc-stylesets(7)` unless explicitly overridden.
|
||||||
|
- Add Message-ID to the variables of `[hooks].mail-received`.
|
||||||
|
- The `TrayInfo` template variable now includes a visual mark mode indicator.
|
||||||
|
- The `disable-ipc` option in `aerc.conf` completely disables IPC.
|
||||||
|
- Improved readability of the builtin `calendar` filter.
|
||||||
|
- `:open` commands now preserve the original filename.
|
||||||
|
- Unparsable accounts are skipped, instead of aerc exiting with an error.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- Built-in descriptions for the default keybinds shown on the review screen
|
||||||
|
will be deprecated in a future release. Descriptions can be added to those
|
||||||
|
keybinds with inline comments in binds.conf.
|
||||||
|
|
||||||
|
## [0.17.0](https://git.sr.ht/~rjarry/aerc/refs/0.17.0) - 2024-02-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- New `flagged` criteria for `:sort`.
|
||||||
|
- New `:send-keys` command to control embedded terminals.
|
||||||
|
- Account aliases now support fnmatch-style wild cards.
|
||||||
|
- New `:suspend` command bound to `<C-z>` by default.
|
||||||
|
- Disable parent context bindings by declaring them empty.
|
||||||
|
- Toggle folding with `:fold -t`.
|
||||||
|
- `mail-deleted` hook that triggers when a message is removed/moved from a
|
||||||
|
folder.
|
||||||
|
- `mail-added` hook that triggers when a message is added to a folder.
|
||||||
|
- Improved command completion.
|
||||||
|
- Customize key to trigger completion with `$complete` in `binds.conf`.
|
||||||
|
- Setting `complete-min-chars=manual` in `aerc.conf` now disables automatic
|
||||||
|
completion, leaving only manually triggered completion.
|
||||||
|
- `.ThreadUnread` is now available in templates.
|
||||||
|
- Allow binding commands to `Alt+<number>` keys.
|
||||||
|
- `AERC_ACCOUNT` and `AERC_ADDRESS_BOOK_CMD` are now defined in the editor's
|
||||||
|
environment when composing a message.
|
||||||
|
- Reply with a different account than the current one with `:reply -A
|
||||||
|
<account>`.
|
||||||
|
- New `[ui].tab-title-viewer` setting to configure the message viewer tab title.
|
||||||
|
- The `{{.Subject}}` template is evaluated to the new option
|
||||||
|
`[ui].empty-subject` if the subject is empty.
|
||||||
|
- Change to a folder of another account with `:cf -a <account> <folder>`.
|
||||||
|
- Patch management with `:patch`.
|
||||||
|
- Add file path to messages in templates as `{{.Filename}}`.
|
||||||
|
- New `:menu` command to invoke other ex-commands based on a shell command
|
||||||
|
output.
|
||||||
|
- CLI flags to override paths to config files.
|
||||||
|
- Automatically attach signing key with `pgp-attach-key` in `accounts.conf`.
|
||||||
|
- Copy messages across accounts with `:cp -a <account> <folder>`.
|
||||||
|
- Move messages across accounts with `:mv -a <account> <folder>`.
|
||||||
|
- Support the `draft` flag.
|
||||||
|
- Thread arrow prefixes are now fully configurable.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `colorize` support for wild cards `?` and `*`.
|
||||||
|
- Selection of headers in composer after `:compose -e` followed by `:edit -E`.
|
||||||
|
- Don't lose child messages of non-queried parents in notmuch threads
|
||||||
|
- Notmuch folders defined by the query `*` handle search, filter, and unread
|
||||||
|
counts correctly.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `:open` commands are now executed with `sh -c`.
|
||||||
|
- `:pipe` commands are now executed with `sh -c`.
|
||||||
|
- Message viewer tab titles will now show `(no subject)` if there is no subject
|
||||||
|
in the viewed email.
|
||||||
|
- Signature placement is now controlled via the `{{.Signature}}` template
|
||||||
|
variable and not hard coded.
|
||||||
|
|
||||||
|
## [0.16.0](https://git.sr.ht/~rjarry/aerc/refs/0.16.0) - 2023-09-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- JMAP support.
|
||||||
|
- The new account wizard now supports all source and outgoing backends.
|
||||||
|
- Edit email headers directly in the text editor with `[compose].edit-headers`
|
||||||
|
in `aerc.conf` or with the `-e` flag for all compose related commands (e.g.
|
||||||
|
`:compose`, `:forward`, `:recall`, etc.).
|
||||||
|
- Use `:save -A` to save all the named parts, not just attachments.
|
||||||
|
- The `<Backspace>` key can now be bound.
|
||||||
|
- `colorize` can style diff chunk function names with `diff_chunk_func`.
|
||||||
|
- Warn before sending emails with an empty subject with `empty-subject-warning`
|
||||||
|
in `aerc.conf`.
|
||||||
|
- IMAP now uses the delimiter advertised by the server.
|
||||||
|
- `carddav-query` utility to use for `address-book-cmd`.
|
||||||
|
- Folder name mapping with `folder-map` in `accounts.conf`.
|
||||||
|
- Use `:open -d` to automatically delete temporary files.
|
||||||
|
- Remove headers from the compose window with `:header -d <name>`.
|
||||||
|
- `:attach -r <name> <cmd>` to pipe the attachments from a command.
|
||||||
|
- New `msglist_gutter` and `msglist_pill` styles for message list scrollbar.
|
||||||
|
- New `%f` placeholder to `file-picker-cmd` which expands to a location of a
|
||||||
|
temporary file from which selected files will be read instead of the standard
|
||||||
|
output.
|
||||||
|
- Save drafts in custom folders with `:postpone -t <folder>`.
|
||||||
|
- View "thread-context" in notmuch backends with `:toggle-thread-context`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `:archive` now works on servers using a different delimiter
|
||||||
|
- `:save -a` now works with multiple attachments with the same filename
|
||||||
|
- `:open` uses the attachment extension for temporary files, if possible
|
||||||
|
- memory leak when using notmuch with threading
|
||||||
|
- `:pipe <cmd>` now executes `sh -c "<cmd>"` as indicated in the man page.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Names formatted like "Last Name, First Name" are better supported in templates
|
||||||
|
- Composing an email is now aborted if the text editor exits with an error
|
||||||
|
(e.g. with `vim`, abort an email with `:cq`).
|
||||||
|
- Aerc builtin filters path (usually `/usr/libexec/aerc/filters`) is now
|
||||||
|
**prepended** to the default system `PATH` to avoid conflicts with installed
|
||||||
|
distro binaries which have the same name as aerc builtin filters (e.g.
|
||||||
|
`/usr/bin/colorize`).
|
||||||
|
- `:export-mbox` only exports marked messages, if any. Otherwise it exports
|
||||||
|
everything, as usual.
|
||||||
|
- The local hostname is no longer exposed in outgoing `Message-Id` headers by
|
||||||
|
default. Legacy behaviour can be restored by setting `send-with-hostname
|
||||||
|
= true` in `accounts.conf`.
|
||||||
|
- The notmuch bindings were replaced with internal bindings
|
||||||
|
- Aerc now has a default style for most UI elements. The `default` styleset is
|
||||||
|
now empty. Existing stylesets will only override the default attributes if
|
||||||
|
they are set explicitly. To reset the default style and preserve existing
|
||||||
|
stylesets appearance, these two lines must be inserted **at the beginning**:
|
||||||
|
|
||||||
|
```
|
||||||
|
*.default=true
|
||||||
|
*.normal=true
|
||||||
|
```
|
||||||
|
- Openers commands are not executed in with `sh -c`.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- Aerc can no longer be compiled and installed with BSD make. GNU make must be
|
||||||
|
used instead.
|
||||||
|
|
||||||
|
## [0.15.2](https://git.sr.ht/~rjarry/aerc/refs/0.15.2) - 2023-05-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Extra messages disappearing when deleting on maildir.
|
||||||
|
- `colorize` and `wrap` filters option parsing on ARM.
|
||||||
|
|
||||||
|
## [0.15.1](https://git.sr.ht/~rjarry/aerc/refs/0.15.1) - 2023-04-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Embedded terminal partial refreshes.
|
||||||
|
- Maildir message updates after `mbsync`.
|
||||||
|
|
||||||
|
## [0.15.0](https://git.sr.ht/~rjarry/aerc/refs/0.15.0) - 2023-04-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- New column-based message list format with `index-columns`.
|
||||||
|
- Add a `msglist_answered` style for answered messages.
|
||||||
|
- Compose `Format=Flowed` messages with `format-flowed=true` in `aerc.conf`.
|
||||||
|
- Add a `trimSignature` function to the templating engine.
|
||||||
|
- Change local domain name for SMTP with `smtp-domain=example.com` in
|
||||||
|
`aerc.conf`
|
||||||
|
- New column-based status line format with `status-columns`.
|
||||||
|
- Inline user-defined styles can be inserted in UI templates via the
|
||||||
|
`{{.Style "name" string}}` function.
|
||||||
|
- Add the ability to run arbitrary commands over the socket. This can be
|
||||||
|
disabled using the `disable-ipc` setting.
|
||||||
|
- Allow configuring URL handlers via `x-scheme-handler/<scheme>` `[openers]` in
|
||||||
|
`aerc.conf`.
|
||||||
|
- Allow basic shell globbing in `[openers]` MIME types.
|
||||||
|
- Dynamic `msglist_*` styling based on email header values in stylesets.
|
||||||
|
- Add `mail-received`, `aerc-startup`, and `aerc-shutdown` hooks.
|
||||||
|
- Search/filter by flags with the `-H` flag.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Filters are now installed in `$PREFIX/libexec/aerc/filters`. The default exec
|
||||||
|
`PATH` has been modified to include all variations of the `libexec` subdirs.
|
||||||
|
- The built-in `colorize` filter theme is now configured in styleset files into
|
||||||
|
the `[viewer]` section.
|
||||||
|
- The standard Usenet signature delimiter `"-- "` is now prepended to
|
||||||
|
`signature-file` and `signature-cmd` if not already present.
|
||||||
|
- All `aerc(1)` commands now interpret `aerc-templates(7)` markup.
|
||||||
|
- running commands (like mailto: or mbox:) no longer prints a success message
|
||||||
|
- The built-in `colorize` filter now emits OSC 8 to mark URLs and emails. Set
|
||||||
|
`[general].enable-osc8 = true` in `aerc.conf` to enable it.
|
||||||
|
- Notmuch support is now automatically enabled when `notmuch.h` is detected on
|
||||||
|
the system.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- `[ui].index-format` setting has been replaced by `index-columns`.
|
||||||
|
- `[statusline].render-format` has been replaced by `status-columns`.
|
||||||
|
- Removed support for go < 1.18.
|
||||||
|
- Removed support for `[ui:subject...]` contextual sections in `aerc.conf`.
|
||||||
|
- `[triggers]` setting has been replaced by `[hooks]`.
|
||||||
|
- `smtp-starttls` setting in `accounts.conf` has been removed. All `smtp://`
|
||||||
|
transports now assume `STARTTLS` and will fail if the server does not support
|
||||||
|
it. To disable `STARTTLS`, use `smtp+insecure://`.
|
||||||
|
|
||||||
|
## [0.14.0](https://git.sr.ht/~rjarry/aerc/refs/0.14.0) - 2023-01-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- View common email envelope headers with `:envelope`.
|
||||||
|
- Notmuch accounts now support maildir operations: `:copy`, `:move`, `:mkdir`,
|
||||||
|
`:rmdir`, `:archive` and the `copy-to` option.
|
||||||
|
- Display messages from bottom to top with `[ui].reverse-msglist-order=true` in
|
||||||
|
`aerc.conf`.
|
||||||
|
- Display threads from bottom to top with `[ui].reverse-thread-order=true` in
|
||||||
|
`aerc.conf`.
|
||||||
|
- Style search results in the message list with `msglist_result.*`.
|
||||||
|
- Preview messages with their attachments before sending with `:preview`.
|
||||||
|
- Filter commands now have `AERC_FORMAT`, `AERC_SUBJECT` and `AERC_FROM`
|
||||||
|
defined in their environment.
|
||||||
|
- Override the subject prefix for replies pattern with `subject-re-pattern` in
|
||||||
|
`accounts.conf`.
|
||||||
|
- Search/filter by absolute and relative date ranges with the `-d` flag.
|
||||||
|
- LIST-STATUS and ORDEREDSUBJECT threading extensions support for imap.
|
||||||
|
- Built-in `wrap` filter that does not mess up nested quotes and lists.
|
||||||
|
- Write `multipart/alternative` messages with `:multipart` and commands defined
|
||||||
|
in the new `[multipart-converters]` section of `aerc.conf`.
|
||||||
|
- Close the message viewer before opening the composer with `:reply -c`.
|
||||||
|
- Attachment indicator in message list flags (by default `a`, but can be
|
||||||
|
changed via `[ui].icon-attachment` in `aerc.conf`).
|
||||||
|
- Open file picker menu with `:attach -m`. The menu must be generated by an
|
||||||
|
external command configured via `[compose].file-picker-cmd` in `aerc.conf`.
|
||||||
|
- Sample stylesets are now installed in `$PREFIX/share/aerc/stylesets`.
|
||||||
|
- The built-in `colorize` filter now has different themes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `pgp-provider` now defaults to `auto`. It will use the system `gpg` unless
|
||||||
|
the internal keyring exists and contains at least one key.
|
||||||
|
- Calling `:split` or `:vsplit` without specifying a size, now attempts to use
|
||||||
|
the terminal size to determine a useful split-size.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `:pipe -m git am -3` on patch series when `Message-Id` headers have not been
|
||||||
|
generated by `git send-email`.
|
||||||
|
- Overflowing text in header editors while composing can now be scrolled
|
||||||
|
horizontally.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- Removed broken `:set` command.
|
||||||
|
|
||||||
|
## [0.13.0](https://git.sr.ht/~rjarry/aerc/refs/0.13.0) - 2022-10-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Support for bindings with the Alt modifier.
|
||||||
|
- Zoxide support with `:z`.
|
||||||
|
- Hide local timezone with `send-as-utc = true` in `accounts.conf`.
|
||||||
|
- Persistent command history in `~/.cache/aerc/history`.
|
||||||
|
- Cursor shape support in embedded terminals.
|
||||||
|
- Bracketed paste support.
|
||||||
|
- Display current directory in `status-line.render-format` with `%p`.
|
||||||
|
- Change accounts while composing a message with `:switch-account`.
|
||||||
|
- Override `:open` handler on a per-MIME-type basis in `aerc.conf`.
|
||||||
|
- Specify opener as the first `:open` param instead of always using default
|
||||||
|
handler (i.e. `:open gimp` to open attachment in GIMP).
|
||||||
|
- Restored XOAUTH2 support for IMAP and SMTP.
|
||||||
|
- Support for attaching files with `mailto:`-links
|
||||||
|
- Filter commands now have the `AERC_MIME_TYPE` and `AERC_FILENAME` variables
|
||||||
|
defined in their environment.
|
||||||
|
- Warn before sending emails that may need an attachment with
|
||||||
|
`no-attachment-warning` in `aerc.conf`.
|
||||||
|
- 3 panel view via `:split` and `:vsplit`
|
||||||
|
- Configure dynamic date format for the message viewer with
|
||||||
|
`message-view-this-*-time-format`.
|
||||||
|
- View message without marking it as seen with `:view -p`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `:open-link` now supports link types other than HTTP(S)
|
||||||
|
- Running the same command multiple times only adds one entry to the command
|
||||||
|
history.
|
||||||
|
- Embedded terminal backend (libvterm was replaced by a pure go implementation).
|
||||||
|
- Filter commands are now executed with
|
||||||
|
`:~/.config/aerc/filters:~/.local/share/aerc/filters:$PREFIX/share/aerc/filters:/usr/share/aerc/filters`
|
||||||
|
appended to the exec `PATH`. This allows referencing aerc's built-in filter
|
||||||
|
scripts from their name only.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `:open-link` will now detect links containing an exclamation mark
|
||||||
|
- `outgoing-cred-cmd` will no longer be executed every time an email needs to
|
||||||
|
be sent. The output will be stored until aerc is shut down. This behaviour
|
||||||
|
can be disabled by setting `outgoing-cred-cmd-cache=false` in
|
||||||
|
`accounts.conf`.
|
||||||
|
- Mouse support for embedded editors when `mouse-enabled=true`.
|
||||||
|
- Numerous race conditions.
|
||||||
|
|
||||||
|
## [0.12.0](https://git.sr.ht/~rjarry/aerc/refs/0.12.0) - 2022-09-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Read-only mbox backend support.
|
||||||
|
- Import/Export mbox files with `:import-mbox` and `:export-mbox`.
|
||||||
|
- `address-book-cmd` can now also be specified in `accounts.conf`.
|
||||||
|
- Run `check-mail-cmd` with `:check-mail`.
|
||||||
|
- Display active key binds with `:help keys` (bound to `?` by default).
|
||||||
|
- Multiple visual selections with `:mark -V`.
|
||||||
|
- Mark all messages of the same thread with `:mark -T`.
|
||||||
|
- Set default collapse depth of directory tree with `dirlist-collapse`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Aerc will no longer exit while a send is in progress.
|
||||||
|
- When scrolling through large folders, client side threading is now debounced
|
||||||
|
to avoid lagging. This can be configured with `client-threads-delay`.
|
||||||
|
- The provided awk filters are now POSIX compliant and should work on MacOS and
|
||||||
|
BSD.
|
||||||
|
- `outgoing-cred-cmd` execution is now deferred until a message needs to be sent.
|
||||||
|
- `next-message-on-delete` now also applies to `:archive`.
|
||||||
|
- `:attach` now supports path globbing (`:attach *.log`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Transient crashes when closing tabs.
|
||||||
|
- Binding a command to `<c-i>` and `<c-m>`.
|
||||||
|
- Reselection after delete and scroll when client side threading is enabled.
|
||||||
|
- Background mail count polling when the default folder is empty on startup.
|
||||||
|
- Wide character handling in the message list.
|
||||||
|
- Issues with message reselection during scrolling and after `:delete` with
|
||||||
|
threading enabled.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- Removed support for go < 1.16.
|
||||||
|
|
||||||
|
## [0.11.0](https://git.sr.ht/~rjarry/aerc/refs/0.11.0) - 2022-07-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Deal with calendar invites with `:accept`, `:accept-tentative` and `:decline`.
|
||||||
|
- IMAP cache support.
|
||||||
|
- Maildir++ support.
|
||||||
|
- Background mail count polling for all folders.
|
||||||
|
- Authentication-Results display (DKIM, SPF & DMARC).
|
||||||
|
- Folder-specific key bindings.
|
||||||
|
- Customizable PGP icons.
|
||||||
|
- Open URLs from messages with `:open-link`.
|
||||||
|
- Forward all individual attachments with `:forward -A`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Messages are now deselected after performing a command. Use `:remark` to
|
||||||
|
reselect the previously selected messages and chain other commands.
|
||||||
|
- Pressing `<Enter>` in the default postpone folder now runs `:recall` instead
|
||||||
|
of `:view`.
|
||||||
|
- PGP signed/encrypted indicators have been reworked.
|
||||||
|
- The `threading-enabled` option now affects if message threading should be
|
||||||
|
enabled at startup. This option no longer conflicts with `:toggle-threads`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `:pipe`, `:save` and `:open` for signed and/or encrypted PGP messages.
|
||||||
|
- Messages that have failed `gpg` encryption/signing are no longer sent.
|
||||||
|
- Recalling attachments from drafts.
|
||||||
|
|
||||||
|
## [0.10.0](https://git.sr.ht/~rjarry/aerc/refs/0.10.0) - 2022-05-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Format specifier for compact folder names in dirlist.
|
||||||
|
- Customizable, per-folder status line.
|
||||||
|
- Allow binding commands to `<` and `>` keys.
|
||||||
|
- Optional filter to parse ICS files (uses `python3` vobject library).
|
||||||
|
- Save all attachments with `:save -a`.
|
||||||
|
- Native `gpg` support.
|
||||||
|
- PGP `auto-sign` and `opportunistic-encrypt` options.
|
||||||
|
- Attach your PGP public key to a message with `:attach-key`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Stack overflow with faulty `References` headers when `:toggle-threads` is
|
||||||
|
enabled.
|
||||||
|
|
||||||
|
## [0.9.0](https://git.sr.ht/~rjarry/aerc/refs/0.9.0) - 2022-03-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Allow `:pipe` on multiple selected messages.
|
||||||
|
- Client side on-the-fly message threading with `:toggle-threads` (conflicts
|
||||||
|
with existing `threading-enabled` option).
|
||||||
|
- Per-account, better status line.
|
||||||
|
- Consecutive, incremental `:search` and `:filter` support.
|
||||||
|
- Foldable tree for directory list.
|
||||||
|
- `Bcc` and `Body` in `mailto:` handler.
|
||||||
|
- Fuzzy tab completion for commands and folders.
|
||||||
|
- Key pass though mode for the message viewer to allow searching with `less`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Use terminfo for setting terminal title.
|
||||||
|
|
||||||
|
## [0.8.2](https://git.sr.ht/~rjarry/aerc/refs/0.8.2) - 2022-02-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- New `colorize` filter with diff, multi-level quotes and URL coloring.
|
||||||
|
- XDG desktop entry to use as default `mailto:` handler.
|
||||||
|
- IMAP automatic reconnect.
|
||||||
|
- Recover drafts after crash with `:recover`.
|
||||||
|
- Show possible actions with user configured bindings when reviewing a message.
|
||||||
|
- Allow setting any header in email templates.
|
||||||
|
- Improved `:change-folder` responsiveness.
|
||||||
|
- New `:compose` option to never include your own address when replying.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Templates and style sets are now searched from multiple directories. Not from
|
||||||
|
a single hard-coded folder set at build time. In addition of the configured
|
||||||
|
`PREFIX/share/aerc` folders at build time, aerc now also looks into
|
||||||
|
`~/.config/aerc`, `~/.local/share/aerc`, `/usr/local/share/aerc` and
|
||||||
|
`/usr/share/aerc`
|
||||||
|
- A warning is displayed when trying to configure account specific bindings
|
||||||
|
for a non-existent account.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `Ctrl-h` binding not working.
|
||||||
|
- Open files leaks for maildir and notmuch.
|
||||||
|
|
||||||
|
## 0.8.1 - 2022-02-20 [YANKED]
|
||||||
|
|
||||||
|
## 0.8.0 - 2022-02-19 [YANKED]
|
||||||
|
|
||||||
|
## [0.7.1](https://git.sr.ht/~rjarry/aerc/refs/0.7.1) - 2022-01-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- IMAP low level TCP settings.
|
||||||
|
- Experimental IMAP server-side and notmuch threading.
|
||||||
|
- `:recall` now works from any folder.
|
||||||
|
- PGP/MIME signing and encryption.
|
||||||
|
- Account specific bindings.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Address book completion for multiple addresses.
|
||||||
|
- Maildir external mailbox changes monitoring.
|
||||||
|
|
||||||
|
## 0.7.0 - 2022-01-14 [YANKED]
|
||||||
|
|
||||||
|
## [0.6.0](https://git.sr.ht/~rjarry/aerc/refs/0.6.0) - 2021-11-09
|
||||||
|
|
||||||
|
*The project was forked to <https://git.sr.ht/~rjarry/aerc>.*
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Allow more modifiers for key bindings.
|
||||||
|
- Dynamic dates in message list.
|
||||||
|
- Match any header in filters specifiers.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Don't read entire messages into memory.
|
||||||
|
|
||||||
|
## [0.5.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.5.0) - 2020-11-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Remove folder with `:rmdir`.
|
||||||
|
- Configurable style sets.
|
||||||
|
- UI context aware options and styling.
|
||||||
|
- oauthbearer support for SMTP.
|
||||||
|
- IMAP sort support.
|
||||||
|
|
||||||
|
## [0.4.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.4.0) - 2020-05-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Address book completion.
|
||||||
|
- Initial PGP support using an internal key store.
|
||||||
|
- Messages can now be selected with `:mark`.
|
||||||
|
- Drafts handing with `:postpone` and `:recall`.
|
||||||
|
- Tab management with `:move-tab` and `:pin-tab`.
|
||||||
|
- Add arbitrary headers in the compose window with `:header`.
|
||||||
|
- Interactive prompt with `:choose`.
|
||||||
|
- Notmuch labels improvements.
|
||||||
|
- Support setting some headers in message templates.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `aerc.conf` ini parser only uses `=` as delimiter. `:` is now ignored.
|
||||||
|
|
||||||
|
## [0.3.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.3.0) - 2019-11-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A new notmuch backend is available. See `aerc-notmuch(5)` for details.
|
||||||
|
- Message templates now let you change the default reply and forwarded message
|
||||||
|
templates, as well as add new templates of your own. See `aerc-templates(7)`
|
||||||
|
for details.
|
||||||
|
- Mouse input is now optionally available and has been rigged up throughout the
|
||||||
|
UI, set `[ui]mouse-enabled=true` in `aerc.conf` to enable.
|
||||||
|
- `:cc` and `:bcc` commands are available in the message composer.
|
||||||
|
- Users may now configure arbitrary message headers for editing in the message
|
||||||
|
composer.
|
||||||
|
|
||||||
|
## [0.2.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.2.0) - 2019-07-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Maildir & sendmail transport support
|
||||||
|
- Search and filtering are supported (via `/` and `\` by default)
|
||||||
|
- `aerc mailto:...` now opens the composer in running aerc instance
|
||||||
|
- Initial tab completion support has been added
|
||||||
|
- Improved headers and addressing in the composer and message view
|
||||||
|
- Message attachments may now be added in the composer
|
||||||
|
- Commands can now be run in the background with `:exec` or `:pipe -b`
|
||||||
|
- A new triggers system allows running aerc commands when new emails arrive,
|
||||||
|
which may (for example) be used to send desktop notifications or move new
|
||||||
|
emails to a folder
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The filters have been rewritten in awk, dropping the Python dependencies.
|
||||||
|
`w3m` and `dante` are both still required for HTML email, but the HTML filter
|
||||||
|
has been commented out in the default config file.
|
||||||
|
- The default keybindings and configuration options have changed considerably,
|
||||||
|
and users are encouraged to pull the latest versions out of `/usr/share` and
|
||||||
|
re-apply their modifications to them, or to at least review the diff with
|
||||||
|
their current configurations. aerc may not behave properly without taking
|
||||||
|
this into account.
|
||||||
|
|
||||||
|
## [0.1.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.1.0) - 2019-06-03
|
||||||
|
|
||||||
|
Initial release.
|
||||||
+359
@@ -0,0 +1,359 @@
|
|||||||
|
# Contribution Guidelines
|
||||||
|
|
||||||
|
This document contains guidelines for contributing code to aerc. It has to be
|
||||||
|
followed in order for your patch to be approved and applied.
|
||||||
|
|
||||||
|
## Contribution Channels
|
||||||
|
|
||||||
|
Anyone can contribute to aerc. First you need to clone the repository and build
|
||||||
|
the project:
|
||||||
|
|
||||||
|
$ git clone https://git.sr.ht/~rjarry/aerc
|
||||||
|
$ cd aerc
|
||||||
|
$ gmake
|
||||||
|
|
||||||
|
Patch the code. Write some tests. Ensure that your code is properly formatted
|
||||||
|
with `gofumpt`. Ensure that everything builds and works as expected. Ensure
|
||||||
|
that you did not break anything.
|
||||||
|
|
||||||
|
- If applicable, update unit tests.
|
||||||
|
- If adding a new feature, please consider adding new tests.
|
||||||
|
- Do not forget to update the docs.
|
||||||
|
- Run the linter using `gmake lint`.
|
||||||
|
|
||||||
|
Once you are happy with your work, you can create a commit (or several
|
||||||
|
commits). Follow these general rules:
|
||||||
|
|
||||||
|
- Limit the first line (title) of the commit message to 60 characters.
|
||||||
|
- Use a short prefix for the commit title for readability with `git log
|
||||||
|
--oneline`. Do not use the `fix:` nor `feature:` prefixes. See recent commits
|
||||||
|
for inspiration.
|
||||||
|
- Only use lower case letters for the commit title except when quoting symbols
|
||||||
|
or known acronyms.
|
||||||
|
- Use the body of the commit message to actually explain what your patch does
|
||||||
|
and why it is useful. Even if your patch is a one line fix, the description
|
||||||
|
is not limited in length and may span over multiple paragraphs. Use proper
|
||||||
|
English syntax, grammar and punctuation.
|
||||||
|
- Address only one issue/topic per commit.
|
||||||
|
- Describe your changes in imperative mood, e.g. *"make xyzzy do frotz"*
|
||||||
|
instead of *"[This patch] makes xyzzy do frotz"* or *"[I] changed xyzzy to do
|
||||||
|
frotz"*, as if you are giving orders to the codebase to change its behaviour.
|
||||||
|
- If you are fixing a ticket, use appropriate
|
||||||
|
[commit trailers](https://man.sr.ht/git.sr.ht/#referencing-tickets-in-git-commit-messages).
|
||||||
|
- If you are fixing a regression introduced by another commit, add a `Fixes:`
|
||||||
|
trailer with the commit id and its title.
|
||||||
|
- When in doubt, follow the format and layout of the recent existing commits.
|
||||||
|
- If your commit brings visible changes for end-users, add one of the following
|
||||||
|
trailers with a short and concise description of the change. The change
|
||||||
|
should be described in full sentences, starting with a capital letter and
|
||||||
|
ending in a period.
|
||||||
|
|
||||||
|
* `Changelog-added:` for new features.
|
||||||
|
* `Changelog-fixed:` for bug fixes.
|
||||||
|
* `Changelog-changed:` for behaviour or config format changes.
|
||||||
|
* `Changelog-deprecated:` for deprecation or removal of functionality.
|
||||||
|
|
||||||
|
If a complete trailer is longer than 72 characters, it can be continued by
|
||||||
|
indenting extra lines with a single space. The trailer text must be valid
|
||||||
|
markdown. You can take inspiration from existing entries in
|
||||||
|
[CHANGELOG.md](https://git.sr.ht/~rjarry/aerc/tree/master/item/CHANGELOG.md).
|
||||||
|
- The following trailers are accepted in commits. If you are using multiple
|
||||||
|
trailers in a commit, it's preferred to also order them according to this
|
||||||
|
list. Note, that the `commit-msg` hook (see below for installing) will
|
||||||
|
automatically sort them for you when committing.
|
||||||
|
|
||||||
|
* `Closes: <URL>` closes the ticket with the neutral `CLOSED` resolution.
|
||||||
|
* `Fixes: <URL>` closes the ticket with the `FIXED` resolution.
|
||||||
|
* `Fixes: <sha> ("<title>")` reference the commit that introduced a regression.
|
||||||
|
* `Implements: <URL>` closes the ticket with the `IMPLEMENTED` resolution.
|
||||||
|
* `References: <URL>` adds a comment to the ticket.
|
||||||
|
* `Link:`
|
||||||
|
* `Changelog-added:`
|
||||||
|
* `Changelog-fixed:`
|
||||||
|
* `Changelog-changed:`
|
||||||
|
* `Changelog-deprecated:`
|
||||||
|
* `Cc:`
|
||||||
|
* `Suggested-by:`
|
||||||
|
* `Requested-by:`
|
||||||
|
* `Reported-by:`
|
||||||
|
* `Co-authored-by:`
|
||||||
|
* `Signed-off-by:` compulsory!
|
||||||
|
* `Tested-by:` used in review _after_ submission to the mailing list. If
|
||||||
|
minimal changes occur between respins, feel free to include that into your
|
||||||
|
respin to keep track of previous reviews.
|
||||||
|
* `Reviewed-by:` used in review _after_ submission. If minimal changes occur
|
||||||
|
between respins, feel free to include that into your respin to keep track
|
||||||
|
of previous reviews.
|
||||||
|
* `Acked-by:` used in review _after_ submission.
|
||||||
|
|
||||||
|
There is a great reference for commit messages in the
|
||||||
|
[Linux kernel documentation](https://www.kernel.org/doc/html/latest/process/submitting-patches.html#describe-your-changes).
|
||||||
|
|
||||||
|
IMPORTANT: you must sign-off your work using `git commit --signoff`. Follow the
|
||||||
|
[Linux kernel developer's certificate of origin][linux-signoff] for more
|
||||||
|
details. All contributions are made under the MIT license. If you do not want
|
||||||
|
to disclose your real name, you may sign-off using a pseudonym. Here is an
|
||||||
|
example:
|
||||||
|
|
||||||
|
Signed-off-by: Robin Jarry <robin@jarry.cc>
|
||||||
|
|
||||||
|
Before sending the patch, you should configure your local clone with sane
|
||||||
|
defaults:
|
||||||
|
|
||||||
|
$ gmake gitconfig
|
||||||
|
git config format.subjectPrefix "PATCH aerc"
|
||||||
|
git config sendemail.to "~rjarry/aerc-devel@lists.sr.ht"
|
||||||
|
git config format.notes true
|
||||||
|
git config notes.rewriteRef refs/notes/commits
|
||||||
|
git config notes.rewriteMode concatenate
|
||||||
|
ln -s ../../contrib/commit-msg .git/hooks/commit-msg
|
||||||
|
+ ln -s ../../contrib/sendemail-validate .git/hooks/sendemail-validate
|
||||||
|
+ git config sendemail.validate true
|
||||||
|
|
||||||
|
And send the patch to the mailing list ([step-by-step
|
||||||
|
instructions][git-send-email-tutorial]):
|
||||||
|
|
||||||
|
$ git send-email --annotate -1
|
||||||
|
|
||||||
|
If you are sending a patch against the `wiki` branch, make sure to change the
|
||||||
|
subject prefix to avoid triggering the automated builds that will inevitably
|
||||||
|
fail:
|
||||||
|
|
||||||
|
$ git send-email --annotate -1 --subject-prefix="PATCH aerc/wiki"
|
||||||
|
|
||||||
|
Before your patch can be applied, it needs to be reviewed and approved by
|
||||||
|
others. They will indicate their approval by replying to your patch with
|
||||||
|
a [Tested-by, Reviewed-by or Acked-by][linux-review] (see also: [the git
|
||||||
|
wiki][git-trailers]) trailer. For example:
|
||||||
|
|
||||||
|
Acked-by: Robin Jarry <robin@jarry.cc>
|
||||||
|
|
||||||
|
There is no "chain of command" in aerc. Anyone that feels comfortable enough to
|
||||||
|
"ack" or "review" a patch should express their opinion freely with an official
|
||||||
|
Acked-by or Reviewed-by trailer. If you only tested that a patch works as
|
||||||
|
expected but did not conduct a proper code review, you can indicate it with
|
||||||
|
a Tested-by trailer.
|
||||||
|
|
||||||
|
You can follow the review process via email and on the [web ui][web-ui].
|
||||||
|
|
||||||
|
Wait for feedback. Address comments and amend changes to your original commit.
|
||||||
|
Then you should send a v2 (and maybe a v3, v4, etc.):
|
||||||
|
|
||||||
|
$ git send-email --annotate -v2 -1
|
||||||
|
|
||||||
|
Be polite, patient and address *all* of the reviewers' remarks. If you disagree
|
||||||
|
with something, feel free to discuss it.
|
||||||
|
|
||||||
|
To help reviewers track what changed between respins of your patch, it is nice
|
||||||
|
to include a mini change log **after** the `---` line that separates your
|
||||||
|
commit message from the diff. You can either do that manually when reviewing
|
||||||
|
(`git send-email --annotate`) before sending your email, or you can use [git
|
||||||
|
notes][git-notes] to make this part of your git workflow:
|
||||||
|
|
||||||
|
$ git notes edit $ref
|
||||||
|
|
||||||
|
[git-notes]: https://git-scm.com/docs/git-notes
|
||||||
|
|
||||||
|
When `format.notes = true` is set in your git configuration, notes attached to
|
||||||
|
commits will automatically be included in the correct location by `git
|
||||||
|
format-patch` and `git send-email`.
|
||||||
|
|
||||||
|
If you have set `notes.rewriteMode = concatenate`, squashing commits together
|
||||||
|
with `git rebase -i` will also merge their respective notes by concatenating
|
||||||
|
them.
|
||||||
|
|
||||||
|
Once your patch has been reviewed and approved (and if the maintainer is OK
|
||||||
|
with it), it will be applied and pushed.
|
||||||
|
|
||||||
|
IMPORTANT: Do NOT use `--in-reply-to` when sending followup versions of a patch
|
||||||
|
set. It causes multiple versions of the same patch to be merged under v1 in the
|
||||||
|
[web ui][web-ui]
|
||||||
|
|
||||||
|
[web-ui]: https://lists.sr.ht/~rjarry/aerc-devel/patches
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
Please refer only to the quoted sections when guidelines are sourced from
|
||||||
|
outside documents as some rules of the source material may conflict with other
|
||||||
|
rules set out in this document.
|
||||||
|
|
||||||
|
When updating an existing file, respect the existing coding style unless there
|
||||||
|
is a good reason not to do so.
|
||||||
|
|
||||||
|
### Indentation
|
||||||
|
|
||||||
|
Indentation rules follow the Linux kernel coding style:
|
||||||
|
|
||||||
|
> Tabs are 8 characters, and thus indentations are also 8 characters. […]
|
||||||
|
>
|
||||||
|
> Rationale: The whole idea behind indentation is to clearly define where
|
||||||
|
> a block of control starts and ends. Especially when you’ve been looking at
|
||||||
|
> your screen for 20 straight hours, you’ll find it a lot easier to see how the
|
||||||
|
> indentation works if you have large indentations.
|
||||||
|
> — [Linux kernel coding style][linux-coding-style]
|
||||||
|
|
||||||
|
### Breaking long lines and strings
|
||||||
|
|
||||||
|
Wrapping rules follow the Linux kernel coding style:
|
||||||
|
|
||||||
|
> Coding style is all about readability and maintainability using commonly
|
||||||
|
> available tools.
|
||||||
|
>
|
||||||
|
> The preferred limit on the length of a single line is 80 columns.
|
||||||
|
>
|
||||||
|
> Statements longer than 80 columns should be broken into sensible chunks,
|
||||||
|
> unless exceeding 80 columns significantly increases readability and does not
|
||||||
|
> hide information.
|
||||||
|
> […]
|
||||||
|
> These same rules are applied to function headers with a long argument list.
|
||||||
|
>
|
||||||
|
> However, never break user-visible strings such as printk messages because
|
||||||
|
> that breaks the ability to grep for them.
|
||||||
|
> — [Linux kernel coding style][linux-coding-style]
|
||||||
|
|
||||||
|
Whether or not wrapping lines is acceptable can be discussed on IRC or the
|
||||||
|
mailing list, when in doubt.
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
Function rules follow the Linux kernel coding style:
|
||||||
|
|
||||||
|
> Functions should be short and sweet, and do just one thing. They should fit
|
||||||
|
> on one or two screenfuls of text (the ISO/ANSI screen size is 80x24, as we
|
||||||
|
> all know), and do one thing and do that well.
|
||||||
|
>
|
||||||
|
> The maximum length of a function is inversely proportional to the complexity
|
||||||
|
> and indentation level of that function. So, if you have a conceptually simple
|
||||||
|
> function that is just one long (but simple) case-statement, where you have to
|
||||||
|
> do lots of small things for a lot of different cases, it’s OK to have
|
||||||
|
> a longer function.
|
||||||
|
>
|
||||||
|
> However, if you have a complex function, and you suspect that
|
||||||
|
> a less-than-gifted first-year high-school student might not even understand
|
||||||
|
> what the function is all about, you should adhere to the maximum limits all
|
||||||
|
> the more closely. Use helper functions with descriptive names (you can ask
|
||||||
|
> the compiler to in-line them if you think it’s performance-critical, and it
|
||||||
|
> will probably do a better job of it than you would have done).
|
||||||
|
>
|
||||||
|
> Another measure of the function is the number of local variables. They
|
||||||
|
> shouldn’t exceed 5-10, or you’re doing something wrong. Re-think the
|
||||||
|
> function, and split it into smaller pieces. A human brain can generally
|
||||||
|
> easily keep track of about 7 different things, anything more and it gets
|
||||||
|
> confused. You know you’re brilliant, but maybe you’d like to understand what
|
||||||
|
> you did 2 weeks from now.
|
||||||
|
> — [Linux kernel coding style][linux-coding-style]
|
||||||
|
|
||||||
|
### Commenting
|
||||||
|
|
||||||
|
Function rules follow the Linux kernel coding style:
|
||||||
|
|
||||||
|
> Comments are good, but there is also a danger of over-commenting. NEVER try
|
||||||
|
> to explain HOW your code works in a comment: it’s much better to write the
|
||||||
|
> code so that the working is obvious, and it’s a waste of time to explain
|
||||||
|
> badly written code.
|
||||||
|
>
|
||||||
|
> Generally, you want your comments to tell WHAT your code does, not HOW. Also,
|
||||||
|
> try to avoid putting comments inside a function body: if the function is so
|
||||||
|
> complex that you need to separately comment parts of it, you should probably
|
||||||
|
> go back to [the previous section regarding functions] for a while. You can
|
||||||
|
> make small comments to note or warn about something particularly clever (or
|
||||||
|
> ugly), but try to avoid excess. Instead, put the comments at the head of the
|
||||||
|
> function, telling people what it does, and possibly WHY it does it.
|
||||||
|
>
|
||||||
|
> When commenting […] API functions, please use the [GoDoc] format. See the
|
||||||
|
> [official documentation][godoc-comments] for details.
|
||||||
|
> — [Linux kernel coding style][linux-coding-style]
|
||||||
|
|
||||||
|
### Editor modelines
|
||||||
|
|
||||||
|
> Some editors can interpret configuration information embedded in source
|
||||||
|
> files, indicated with special markers. For example, emacs interprets lines
|
||||||
|
> marked like this:
|
||||||
|
>
|
||||||
|
> -*- mode: c -*-
|
||||||
|
>
|
||||||
|
> Or like this:
|
||||||
|
>
|
||||||
|
> /*
|
||||||
|
> Local Variables:
|
||||||
|
> compile-command: "gcc -DMAGIC_DEBUG_FLAG foo.c"
|
||||||
|
> End:
|
||||||
|
> */
|
||||||
|
>
|
||||||
|
> Vim interprets markers that look like this:
|
||||||
|
>
|
||||||
|
> /* vim:set sw=8 noet */
|
||||||
|
>
|
||||||
|
> Do not include any of these in source files. People have their own personal
|
||||||
|
> editor configurations, and your source files should not override them. This
|
||||||
|
> includes markers for indentation and mode configuration. People may use
|
||||||
|
> their own custom mode, or may have some other magic method for making
|
||||||
|
> indentation work correctly.
|
||||||
|
> — [Linux kernel coding style][linux-coding-style]
|
||||||
|
|
||||||
|
In the same way, files specific to only your workflow (for example the `.idea`
|
||||||
|
or `.vscode` directory) are not desired. If a script might be useful to other
|
||||||
|
contributors, it can be sent as a separate patch that adds it to the `contrib`
|
||||||
|
directory. Since it is not editor-specific, an
|
||||||
|
[`.editorconfig`](https://git.sr.ht/~rjarry/aerc/tree/master/item/.editorconfig)
|
||||||
|
is available in the repository.
|
||||||
|
|
||||||
|
### Go-code
|
||||||
|
|
||||||
|
The Go-code follows the rules of [gofumpt][gofumpt-repo] which is equivalent to
|
||||||
|
gofmt but adds a few additional rules. The code can be automatically formatted
|
||||||
|
by running `gmake fmt`.
|
||||||
|
|
||||||
|
If gofumpt accepts your code it's most likely properly formatted.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Aerc allows logging messages to a file. Either by redirecting the output to
|
||||||
|
a file (e.g. `aerc > log`), or by configuring `log-file` in `aerc.conf`.
|
||||||
|
Logging messages are associated with a severity level, from lowest to highest:
|
||||||
|
`trace`, `debug`, `info`, `warn`, `error`.
|
||||||
|
|
||||||
|
Messages can be sent to the log file by using the following functions:
|
||||||
|
|
||||||
|
- `log.Errorf()`: Use to report serious (but non-fatal) errors.
|
||||||
|
- `log.Warnf()`: Use to report issues that do not affect normal use.
|
||||||
|
- `log.Infof()`: Use to display important messages that may concern
|
||||||
|
non-developers.
|
||||||
|
- `log.Debugf()`: Use to display non-important messages, or debugging
|
||||||
|
details.
|
||||||
|
- `log.Tracef()`: Use to display only low level debugging traces.
|
||||||
|
|
||||||
|
### Man pages
|
||||||
|
|
||||||
|
All `doc/*.scd` files are written in the [scdoc][scdoc] format and compiled to
|
||||||
|
man pages.
|
||||||
|
|
||||||
|
For consistent rendering, please respect the following guidelines:
|
||||||
|
|
||||||
|
- use `*:command*` to reference commands
|
||||||
|
- use `*-x*` for flags
|
||||||
|
- use `_<arg>_` argument placeholders that must be replaced by a suitable value
|
||||||
|
- use `_foobar.conf_` for file paths
|
||||||
|
- use `_true_`, `_0_`, `_constant_` for literal constants that must be typed as is
|
||||||
|
- use `[*-x*]` or `[_<arg>_]` for optional flags/arguments
|
||||||
|
- use `*-x*|*-y*` for mutually exclusive flags/arguments
|
||||||
|
- use `*[section]*` to reference sections in configuration files
|
||||||
|
- use `*foo*` or `*[section].foo*` to reference settings
|
||||||
|
- if an option does **not** have a default value, simply omit it
|
||||||
|
- use `*FOO*` and `*$FOO*` for environment variables
|
||||||
|
- only use `_"quoted values"_` when white space matters
|
||||||
|
- put command alternatives/aliases on separate lines with `++` suffixes
|
||||||
|
- use `*<c-x>*` or `*<enter>*` to reference key strokes
|
||||||
|
- use `# UPPER CASE` for man page sections
|
||||||
|
- use `*aerc-config*(5)` to reference other man pages
|
||||||
|
- use `aerc` (instead of `*aerc*` or `_aerc_`) to reference the aerc project or
|
||||||
|
the aerc program
|
||||||
|
|
||||||
|
[git-send-email-tutorial]: https://git-send-email.io/
|
||||||
|
[git-trailers]: https://git.wiki.kernel.org/index.php/CommitMessageConventions
|
||||||
|
[godoc-comments]: https://go.dev/blog/godoc
|
||||||
|
[gofumpt-repo]: https://github.com/mvdan/gofumpt
|
||||||
|
[linux-coding-style]: https://www.kernel.org/doc/html/v5.19-rc8/process/coding-style.html
|
||||||
|
[linux-review]: https://www.kernel.org/doc/html/latest/process/submitting-patches.html#using-reported-by-tested-by-reviewed-by-suggested-by-and-fixes
|
||||||
|
[linux-signoff]: https://www.kernel.org/doc/html/latest/process/submitting-patches.html#sign-your-work-the-developer-s-certificate-of-origin
|
||||||
|
[scdoc]: https://git.sr.ht/~sircmpwn/scdoc
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
# variables that can be changed by users
|
||||||
|
#
|
||||||
|
VERSION ?= $(shell git describe --long --abbrev=12 --tags --dirty 2>/dev/null || echo 0.20.0)
|
||||||
|
DATE ?= $(shell date +%Y-%m-%d)
|
||||||
|
PREFIX ?= /usr/local
|
||||||
|
BINDIR ?= $(PREFIX)/bin
|
||||||
|
SHAREDIR ?= $(PREFIX)/share/aerc
|
||||||
|
LIBEXECDIR ?= $(PREFIX)/libexec/aerc
|
||||||
|
MANDIR ?= $(PREFIX)/share/man
|
||||||
|
GO ?= go
|
||||||
|
INSTALL ?= install
|
||||||
|
CP ?= cp
|
||||||
|
GOFLAGS ?= $(shell contrib/goflags.sh)
|
||||||
|
BUILD_OPTS ?= -trimpath
|
||||||
|
GO_LDFLAGS :=
|
||||||
|
GO_LDFLAGS += -X main.Version=$(VERSION)
|
||||||
|
GO_LDFLAGS += -X main.Date=$(DATE)
|
||||||
|
GO_LDFLAGS += -X git.sr.ht/~rjarry/aerc/config.shareDir=$(SHAREDIR)
|
||||||
|
GO_LDFLAGS += -X git.sr.ht/~rjarry/aerc/config.libexecDir=$(LIBEXECDIR)
|
||||||
|
GO_LDFLAGS += $(GO_EXTRA_LDFLAGS)
|
||||||
|
CC ?= cc
|
||||||
|
CFLAGS ?= -O2 -g
|
||||||
|
|
||||||
|
# internal variables used for automatic rules generation with macros
|
||||||
|
gosrc = $(shell find * -type f -name '*.go') go.mod go.sum
|
||||||
|
man1 = $(subst .scd,,$(notdir $(wildcard doc/*.1.scd)))
|
||||||
|
man5 = $(subst .scd,,$(notdir $(wildcard doc/*.5.scd)))
|
||||||
|
man7 = $(subst .scd,,$(notdir $(wildcard doc/*.7.scd)))
|
||||||
|
docs = $(man1) $(man5) $(man7)
|
||||||
|
cfilters = $(subst .c,,$(notdir $(wildcard filters/*.c)))
|
||||||
|
filters = $(filter-out filters/vectors filters/test.sh filters/%.c,$(wildcard filters/*))
|
||||||
|
gofumpt_tag = v0.7.0
|
||||||
|
|
||||||
|
# Dependencies are added dynamically to the "all" rule with macros
|
||||||
|
.PHONY: all
|
||||||
|
all: aerc
|
||||||
|
@:
|
||||||
|
|
||||||
|
aerc: $(gosrc)
|
||||||
|
$(GO) build $(BUILD_OPTS) $(GOFLAGS) -ldflags "$(GO_LDFLAGS)" -o aerc
|
||||||
|
|
||||||
|
.PHONY: dev
|
||||||
|
dev:
|
||||||
|
$(RM) aerc
|
||||||
|
$(MAKE) --no-print-directory aerc BUILD_OPTS="-trimpath -race"
|
||||||
|
GORACE="log_path=race.log strip_path_prefix=git.sr.ht/~rjarry/aerc/" ./aerc
|
||||||
|
|
||||||
|
.PHONY: fmt
|
||||||
|
fmt:
|
||||||
|
$(GO) run mvdan.cc/gofumpt@$(gofumpt_tag) -w .
|
||||||
|
|
||||||
|
.PHONY: lint
|
||||||
|
lint:
|
||||||
|
@contrib/check-whitespace `git ls-files ':!:filters/vectors'` && \
|
||||||
|
echo white space ok.
|
||||||
|
@contrib/check-docs && echo docs ok.
|
||||||
|
@$(GO) run mvdan.cc/gofumpt@$(gofumpt_tag) -d . | grep ^ \
|
||||||
|
&& echo The above files need to be formatted, please run make fmt && exit 1 \
|
||||||
|
|| echo all files formatted.
|
||||||
|
codespell *
|
||||||
|
$(GO) run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2 run \
|
||||||
|
$$(echo $(GOFLAGS) | sed s/-tags=/--build-tags=/)
|
||||||
|
$(GO) run $(GOFLAGS) contrib/linters.go ./...
|
||||||
|
|
||||||
|
.PHONY: vulncheck
|
||||||
|
vulncheck:
|
||||||
|
$(GO) run golang.org/x/vuln/cmd/govulncheck@latest ./...
|
||||||
|
|
||||||
|
.PHONY: tests
|
||||||
|
tests: $(cfilters)
|
||||||
|
$(GO) test $(GOFLAGS) ./...
|
||||||
|
filters/test.sh
|
||||||
|
|
||||||
|
.PHONY: debug
|
||||||
|
debug: aerc.debug
|
||||||
|
@echo 'Run `./aerc.debug` and use this command in another terminal to attach a debugger:'
|
||||||
|
@echo ' dlv attach $$(pidof aerc.debug)'
|
||||||
|
|
||||||
|
aerc.debug: $(gosrc)
|
||||||
|
$(GO) build $(subst -trimpath,,$(GOFLAGS)) -gcflags=all="-N -l" -ldflags="$(GO_LDFLAGS)" -o aerc.debug
|
||||||
|
|
||||||
|
.PHONY: doc
|
||||||
|
doc: $(docs)
|
||||||
|
@:
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
$(RM) $(docs) aerc $(cfilters)
|
||||||
|
|
||||||
|
# Dependencies are added dynamically to the "install" rule with macros
|
||||||
|
.PHONY: install
|
||||||
|
install:
|
||||||
|
@:
|
||||||
|
|
||||||
|
.PHONY: checkinstall
|
||||||
|
checkinstall:
|
||||||
|
$(DESTDIR)$(BINDIR)/aerc -v
|
||||||
|
for m in $(man1); do test -e $(DESTDIR)$(MANDIR)/man1/$$m || exit; done
|
||||||
|
for m in $(man5); do test -e $(DESTDIR)$(MANDIR)/man5/$$m || exit; done
|
||||||
|
for m in $(man7); do test -e $(DESTDIR)$(MANDIR)/man7/$$m || exit; done
|
||||||
|
|
||||||
|
.PHONY: uninstall
|
||||||
|
uninstall:
|
||||||
|
@echo $(installed) | tr ' ' '\n' | sort -ru | while read -r f; do \
|
||||||
|
echo rm -f $$f && rm -f $$f || exit; \
|
||||||
|
done
|
||||||
|
@echo $(dirs) | tr ' ' '\n' | sort -ru | while read -r d; do \
|
||||||
|
if [ -d $$d ] && ! ls -Aq1 $$d | grep -q .; then \
|
||||||
|
echo rmdir $$d && rmdir $$d || exit; \
|
||||||
|
fi; \
|
||||||
|
done
|
||||||
|
|
||||||
|
.PHONY: gitconfig
|
||||||
|
gitconfig:
|
||||||
|
git config format.subjectPrefix "PATCH aerc"
|
||||||
|
git config sendemail.to "~rjarry/aerc-devel@lists.sr.ht"
|
||||||
|
git config format.notes true
|
||||||
|
git config notes.rewriteRef refs/notes/commits
|
||||||
|
git config notes.rewriteMode concatenate
|
||||||
|
@mkdir -p .git/hooks
|
||||||
|
@rm -f .git/hooks/commit-msg*
|
||||||
|
ln -s ../../contrib/commit-msg .git/hooks/commit-msg
|
||||||
|
@rm -f .git/hooks/sendemail-validate*
|
||||||
|
@if grep -q GIT_SENDEMAIL_FILE_COUNTER `git --exec-path`/git-send-email 2>/dev/null; then \
|
||||||
|
set -xe; \
|
||||||
|
ln -s ../../contrib/sendemail-validate .git/hooks/sendemail-validate && \
|
||||||
|
git config sendemail.validate true; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
.PHONY: check-patches
|
||||||
|
check-patches:
|
||||||
|
@contrib/check-patches origin/master..
|
||||||
|
|
||||||
|
.PHONY: validate
|
||||||
|
validate: CFLAGS = -Wall -Wextra -Wconversion -Werror -Wformat-security -Wstack-protector -Wpedantic -Wmissing-prototypes
|
||||||
|
validate: all tests lint check-patches
|
||||||
|
|
||||||
|
# Generate build and install rules for one man page
|
||||||
|
#
|
||||||
|
# $1: man page name (e.g: aerc.1)
|
||||||
|
#
|
||||||
|
define install_man
|
||||||
|
$1: doc/$1.scd
|
||||||
|
scdoc < $$< > $$@
|
||||||
|
|
||||||
|
$1_section = $$(subst .,,$$(suffix $1))
|
||||||
|
$1_install_dir = $$(DESTDIR)$$(MANDIR)/man$$($1_section)
|
||||||
|
dirs += $$($1_install_dir)
|
||||||
|
installed += $$($1_install_dir)/$1
|
||||||
|
|
||||||
|
$$($1_install_dir)/$1: $1 | $$($1_install_dir)
|
||||||
|
$$(INSTALL) -m644 $$< $$@
|
||||||
|
|
||||||
|
all: $1
|
||||||
|
install: $$($1_install_dir)/$1
|
||||||
|
endef
|
||||||
|
|
||||||
|
# Generate build and install rules for one filter
|
||||||
|
#
|
||||||
|
# $1: filter source path or name
|
||||||
|
#
|
||||||
|
define install_filter
|
||||||
|
ifneq ($(wildcard filters/$1.c),)
|
||||||
|
$1: filters/$1.c
|
||||||
|
$$(CC) $$(CFLAGS) $$(CPPFLAGS) $$(LDFLAGS) -o $$@ $$<
|
||||||
|
|
||||||
|
all: $1
|
||||||
|
endif
|
||||||
|
|
||||||
|
$1_install_dir = $$(DESTDIR)$$(LIBEXECDIR)/filters
|
||||||
|
dirs += $$($1_install_dir)
|
||||||
|
installed += $$($1_install_dir)/$$(notdir $1)
|
||||||
|
|
||||||
|
$$($1_install_dir)/$$(notdir $1): $1 | $$($1_install_dir)
|
||||||
|
$$(CP) -af $$< $$@
|
||||||
|
|
||||||
|
install: $$($1_install_dir)/$$(notdir $1)
|
||||||
|
endef
|
||||||
|
|
||||||
|
# Generate install rules for any file
|
||||||
|
#
|
||||||
|
# $1: source file
|
||||||
|
# $2: mode
|
||||||
|
# $3: target dir
|
||||||
|
#
|
||||||
|
define install_file
|
||||||
|
dirs += $3
|
||||||
|
installed += $3/$$(notdir $1)
|
||||||
|
|
||||||
|
$3/$$(notdir $1): $1 | $3
|
||||||
|
$$(INSTALL) -m$2 $$< $$@
|
||||||
|
|
||||||
|
install: $3/$$(notdir $1)
|
||||||
|
endef
|
||||||
|
|
||||||
|
# Call macros to generate build and install rules
|
||||||
|
$(foreach m,$(docs),\
|
||||||
|
$(eval $(call install_man,$m)))
|
||||||
|
$(foreach f,$(filters) $(cfilters),\
|
||||||
|
$(eval $(call install_filter,$f)))
|
||||||
|
$(foreach f,$(wildcard config/*.conf),\
|
||||||
|
$(eval $(call install_file,$f,644,$(DESTDIR)$(SHAREDIR))))
|
||||||
|
$(foreach s,$(wildcard stylesets/*),\
|
||||||
|
$(eval $(call install_file,$s,644,$(DESTDIR)$(SHAREDIR)/stylesets)))
|
||||||
|
$(foreach t,$(wildcard templates/*),\
|
||||||
|
$(eval $(call install_file,$t,644,$(DESTDIR)$(SHAREDIR)/templates)))
|
||||||
|
$(eval $(call install_file,contrib/aerc.desktop,644,$(DESTDIR)$(PREFIX)/share/applications))
|
||||||
|
$(eval $(call install_file,aerc,755,$(DESTDIR)$(BINDIR)))
|
||||||
|
$(eval $(call install_file,contrib/carddav-query,755,$(DESTDIR)$(BINDIR)))
|
||||||
|
|
||||||
|
$(sort $(dirs)):
|
||||||
|
mkdir -p $@
|
||||||
|
|
||||||
|
.DELETE_ON_ERROR:
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
Copyright (c) 2018-2019 Drew DeVault
|
||||||
|
Copyright (c) 2021-2022 Robin Jarry
|
||||||
|
|
||||||
|
The MIT License
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
Below is a list of maintainers of this project. They have push access to the
|
||||||
|
git repository, moderation access to the mailing list and the bug tracker.
|
||||||
|
|
||||||
|
Git Push Access
|
||||||
|
===============
|
||||||
|
|
||||||
|
Robin Jarry <robin@jarry.cc>
|
||||||
|
|
||||||
|
List Moderation, Patch Triage, Bug Tracker Triage
|
||||||
|
=================================================
|
||||||
|
|
||||||
|
Bence Ferdinandy <bence@ferdinandy.com>
|
||||||
|
Inwit <inwit@sindominio.net>
|
||||||
|
Koni Marti <koni.marti@gmail.com>
|
||||||
|
Moritz Poldrack <moritz@poldrack.dev>
|
||||||
|
Tim Culverhouse <tim@timculverhouse.com>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# This file is only left here for explicit error about GNU make requirement
|
||||||
|
# when building with other make flavours.
|
||||||
|
#
|
||||||
|
# Do not edit this file. Edit GNUmakefile instead.
|
||||||
|
.PHONY: all
|
||||||
|
all .DEFAULT:
|
||||||
|
@echo "Please build and install using GNU make (gmake)"; exit 1
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# aerc
|
||||||
|
|
||||||
|
[](https://builds.sr.ht/~rjarry/aerc)
|
||||||
|
[](https://github.com/rjarry/aerc/actions/workflows/macos.yml)
|
||||||
|
|
||||||
|
[aerc](https://sr.ht/~rjarry/aerc/) is an email client for your terminal.
|
||||||
|
|
||||||
|
This is a fork of [the original aerc](https://git.sr.ht/~sircmpwn/aerc)
|
||||||
|
by Drew DeVault.
|
||||||
|
|
||||||
|
A short demonstration can be found on [https://aerc-mail.org/](https://aerc-mail.org/)
|
||||||
|
|
||||||
|
Join the IRC channel: [#aerc on irc.libera.chat](http://web.libera.chat/?channels=aerc)
|
||||||
|
for end-user support, and development.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
On its first run, aerc will copy the default config files to `~/.config/aerc`
|
||||||
|
on Linux or `~/Library/Preferences/aerc` on MacOS (or `$XDG_CONFIG_HOME/aerc` if set)
|
||||||
|
and show the account configuration wizard.
|
||||||
|
|
||||||
|
If you redirect stdout to a file, logging output will be written to that file:
|
||||||
|
|
||||||
|
$ aerc > log
|
||||||
|
|
||||||
|
Note that the default HTML filter additionally needs `w3m` to be installed
|
||||||
|
along with optional `unshare` (from `util-linux`) or `socksify` (from
|
||||||
|
`dante-utils`).
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
Also available as man pages:
|
||||||
|
|
||||||
|
- [aerc(1)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc.1.scd)
|
||||||
|
- [aerc-accounts(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-accounts.5.scd)
|
||||||
|
- [aerc-binds(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-binds.5.scd)
|
||||||
|
- [aerc-config(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-config.5.scd)
|
||||||
|
- [aerc-imap(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-imap.5.scd)
|
||||||
|
- [aerc-jmap(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-jmap.5.scd)
|
||||||
|
- [aerc-maildir(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-maildir.5.scd)
|
||||||
|
- [aerc-notmuch(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-notmuch.5.scd)
|
||||||
|
- [aerc-patch(7)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-patch.7.scd)
|
||||||
|
- [aerc-search(1)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-search.1.scd)
|
||||||
|
- [aerc-sendmail(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-sendmail.5.scd)
|
||||||
|
- [aerc-smtp(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-smtp.5.scd)
|
||||||
|
- [aerc-stylesets(7)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-stylesets.7.scd)
|
||||||
|
- [aerc-templates(7)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-templates.7.scd)
|
||||||
|
- [aerc-tutorial(7)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-tutorial.7.scd)
|
||||||
|
- [carddav-query(1)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/carddav-query.1.scd)
|
||||||
|
|
||||||
|
User contributions and integration with external tools:
|
||||||
|
|
||||||
|
- [wiki](https://man.sr.ht/~rjarry/aerc/)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Binary Packages
|
||||||
|
|
||||||
|
Recent versions of aerc are available on:
|
||||||
|
|
||||||
|
- [Alpine](https://pkgs.alpinelinux.org/packages?name=aerc)
|
||||||
|
- [Arch](https://archlinux.org/packages/extra/x86_64/aerc/)
|
||||||
|
- [Debian](https://tracker.debian.org/pkg/aerc)
|
||||||
|
- [Fedora](https://packages.fedoraproject.org/pkgs/aerc/aerc/)
|
||||||
|
- [openSUSE](https://build.opensuse.org/package/show/openSUSE:Factory/aerc)
|
||||||
|
- [macOS through Homebrew](https://formulae.brew.sh/formula/aerc)
|
||||||
|
- [Slackware](https://slackbuilds.org/result/?search=aerc)
|
||||||
|
|
||||||
|
And likely other platforms.
|
||||||
|
|
||||||
|
### From Source
|
||||||
|
|
||||||
|
Install the dependencies:
|
||||||
|
|
||||||
|
- go (>=1.21) *(Go versions are supported until their end-of-life; support for
|
||||||
|
older versions may be dropped at any time due to incompatibilities or newer
|
||||||
|
required language features.)*
|
||||||
|
- [scdoc](https://git.sr.ht/~sircmpwn/scdoc)
|
||||||
|
- GNU make
|
||||||
|
|
||||||
|
Then compile aerc:
|
||||||
|
|
||||||
|
$ gmake
|
||||||
|
|
||||||
|
aerc optionally supports notmuch. To enable it, you need to have a recent
|
||||||
|
version of [notmuch](https://notmuchmail.org/#index7h2), including the header
|
||||||
|
files (notmuch.h). The `notmuch` build tag should be automatically added. To
|
||||||
|
check if it is, run the following command:
|
||||||
|
|
||||||
|
$ ./aerc -v
|
||||||
|
aerc 0.14.0-108-g31e1cd9af565 +notmuch (go1.19.6 amd64 linux)
|
||||||
|
^^^^^^^^
|
||||||
|
|
||||||
|
If it is not, you can force it before building:
|
||||||
|
|
||||||
|
$ gmake GOFLAGS=-tags=notmuch
|
||||||
|
|
||||||
|
If you have notmuch headers available but do not want to build notmuch support
|
||||||
|
in aerc, force GOFLAGS to an empty value:
|
||||||
|
|
||||||
|
$ gmake GOFLAGS=
|
||||||
|
|
||||||
|
To install aerc locally:
|
||||||
|
|
||||||
|
# gmake install
|
||||||
|
|
||||||
|
By default, aerc will install config files to directories under `/usr/local/aerc`,
|
||||||
|
and will search for templates and stylesets in these locations in order:
|
||||||
|
|
||||||
|
- `${XDG_CONFIG_HOME:-~/.config}/aerc`
|
||||||
|
- `${XDG_DATA_HOME:-~/.local/share}/aerc`
|
||||||
|
- `/usr/local/share/aerc`
|
||||||
|
- `/usr/share/aerc`
|
||||||
|
|
||||||
|
At build time it is possible to add an extra location to this list and to use
|
||||||
|
that location as the default install location for config files by setting the
|
||||||
|
`PREFIX` option like so:
|
||||||
|
|
||||||
|
# gmake PREFIX=/custom/location
|
||||||
|
# gmake install PREFIX=/custom/location
|
||||||
|
|
||||||
|
This will install templates and other config files to `/custom/location/share/aerc`,
|
||||||
|
and man pages to `/custom/location/share/man`. This extra location will have lower
|
||||||
|
priority than the XDG locations but higher than the fixed paths.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Anyone can contribute to aerc. Please refer to [the contribution
|
||||||
|
guidelines](https://git.sr.ht/~rjarry/aerc/tree/master/item/CONTRIBUTING.md)
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
Ask for support or follow general discussions on
|
||||||
|
[~rjarry/aerc-discuss@lists.sr.ht](https://lists.sr.ht/~rjarry/aerc-discuss).
|
||||||
|
|
||||||
|
Send patches and development related questions to
|
||||||
|
[~rjarry/aerc-devel@lists.sr.ht](https://lists.sr.ht/~rjarry/aerc-devel).
|
||||||
|
|
||||||
|
Instructions for preparing a patch are available at
|
||||||
|
[git-send-email.io](https://git-send-email.io)
|
||||||
|
|
||||||
|
Subscribe to release announcements on
|
||||||
|
[~rjarry/aerc-announce@lists.sr.ht](https://lists.sr.ht/~rjarry/aerc-announce)
|
||||||
|
|
||||||
|
Submit *confirmed* bug reports and *confirmed* feature requests on
|
||||||
|
[https://todo.sr.ht/~rjarry/aerc](https://todo.sr.ht/~rjarry/aerc).
|
||||||
|
|
||||||
|
[License](https://git.sr.ht/~rjarry/aerc/tree/master/item/LICENSE).
|
||||||
|
|
||||||
|
[Change log](https://git.sr.ht/~rjarry/aerc/tree/master/item/CHANGELOG.md).
|
||||||
@@ -0,0 +1,886 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/emersion/go-message/mail"
|
||||||
|
"github.com/go-ini/ini"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/format"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CONFIGURE_BASICS = iota
|
||||||
|
CONFIGURE_SOURCE = iota
|
||||||
|
CONFIGURE_OUTGOING = iota
|
||||||
|
CONFIGURE_COMPLETE = iota
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountWizard struct {
|
||||||
|
step int
|
||||||
|
steps []*ui.Grid
|
||||||
|
focus int
|
||||||
|
temporary bool
|
||||||
|
// CONFIGURE_BASICS
|
||||||
|
accountName *ui.TextInput
|
||||||
|
email *ui.TextInput
|
||||||
|
discovered map[string]string
|
||||||
|
fullName *ui.TextInput
|
||||||
|
basics []ui.Interactive
|
||||||
|
// CONFIGURE_SOURCE
|
||||||
|
sourceProtocol *Selector
|
||||||
|
sourceTransport *Selector
|
||||||
|
|
||||||
|
sourceUsername *ui.TextInput
|
||||||
|
sourcePassword *ui.TextInput
|
||||||
|
sourceServer *ui.TextInput
|
||||||
|
sourceStr *ui.Text
|
||||||
|
sourceUrl url.URL
|
||||||
|
source []ui.Interactive
|
||||||
|
// CONFIGURE_OUTGOING
|
||||||
|
outgoingProtocol *Selector
|
||||||
|
outgoingTransport *Selector
|
||||||
|
|
||||||
|
outgoingUsername *ui.TextInput
|
||||||
|
outgoingPassword *ui.TextInput
|
||||||
|
outgoingServer *ui.TextInput
|
||||||
|
outgoingStr *ui.Text
|
||||||
|
outgoingUrl url.URL
|
||||||
|
outgoingCopyTo *ui.TextInput
|
||||||
|
outgoing []ui.Interactive
|
||||||
|
// CONFIGURE_COMPLETE
|
||||||
|
complete []ui.Interactive
|
||||||
|
}
|
||||||
|
|
||||||
|
func showPasswordWarning() {
|
||||||
|
title := "ATTENTION"
|
||||||
|
text := `
|
||||||
|
The Wizard will store your passwords as clear text in:
|
||||||
|
|
||||||
|
~/.config/aerc/accounts.conf
|
||||||
|
|
||||||
|
It is recommended to remove the clear text passwords and configure
|
||||||
|
'source-cred-cmd' and 'outgoing-cred-cmd' using your own password store
|
||||||
|
after the setup.
|
||||||
|
`
|
||||||
|
warning := NewSelectorDialog(
|
||||||
|
title, text, []string{"OK"}, 0,
|
||||||
|
SelectedAccountUiConfig(),
|
||||||
|
func(_ string, _ error) {
|
||||||
|
CloseDialog()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AddDialog(warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
type configStep struct {
|
||||||
|
introduction string
|
||||||
|
labels []string
|
||||||
|
fields []ui.Drawable
|
||||||
|
interactive *[]ui.Interactive
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfigStep(intro string, interactive *[]ui.Interactive) configStep {
|
||||||
|
return configStep{introduction: intro, interactive: interactive}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *configStep) AddField(label string, field ui.Drawable) {
|
||||||
|
s.labels = append(s.labels, label)
|
||||||
|
s.fields = append(s.fields, field)
|
||||||
|
if i, ok := field.(ui.Interactive); ok {
|
||||||
|
*s.interactive = append(*s.interactive, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *configStep) Grid() *ui.Grid {
|
||||||
|
introduction := strings.TrimSpace(s.introduction)
|
||||||
|
h := strings.Count(introduction, "\n") + 1
|
||||||
|
spec := []ui.GridSpec{
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // padding
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(h)}, // intro text
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // padding
|
||||||
|
}
|
||||||
|
for range s.fields {
|
||||||
|
spec = append(spec, []ui.GridSpec{
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // label
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // field
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // padding
|
||||||
|
}...)
|
||||||
|
}
|
||||||
|
justify := ui.GridSpec{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}
|
||||||
|
spec = append(spec, justify)
|
||||||
|
grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{justify})
|
||||||
|
|
||||||
|
intro := ui.NewText(introduction, config.Ui.GetStyle(config.STYLE_DEFAULT))
|
||||||
|
fill := ui.NewFill(' ', vaxis.Style{})
|
||||||
|
|
||||||
|
grid.AddChild(fill).At(0, 0)
|
||||||
|
grid.AddChild(intro).At(1, 0)
|
||||||
|
grid.AddChild(fill).At(2, 0)
|
||||||
|
|
||||||
|
row := 3
|
||||||
|
for i, field := range s.fields {
|
||||||
|
label := ui.NewText(s.labels[i], config.Ui.GetStyle(config.STYLE_HEADER))
|
||||||
|
grid.AddChild(label).At(row, 0)
|
||||||
|
grid.AddChild(field).At(row+1, 0)
|
||||||
|
grid.AddChild(fill).At(row+2, 0)
|
||||||
|
row += 3
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.AddChild(fill).At(row, 0)
|
||||||
|
|
||||||
|
return grid
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// protocols
|
||||||
|
IMAP = "IMAP"
|
||||||
|
JMAP = "JMAP"
|
||||||
|
MAILDIR = "Maildir"
|
||||||
|
MAILDIRPP = "Maildir++"
|
||||||
|
NOTMUCH = "notmuch"
|
||||||
|
SMTP = "SMTP"
|
||||||
|
SENDMAIL = "sendmail"
|
||||||
|
// transports
|
||||||
|
SSL_TLS = "SSL/TLS"
|
||||||
|
OAUTH = "SSL/TLS+OAUTHBEARER"
|
||||||
|
XOAUTH = "SSL/TLS+XOAUTH2"
|
||||||
|
STARTTLS = "STARTTLS"
|
||||||
|
INSECURE = "Insecure"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sources = []string{IMAP, JMAP, MAILDIR, MAILDIRPP, NOTMUCH}
|
||||||
|
outgoings = []string{SMTP, JMAP, SENDMAIL}
|
||||||
|
transports = []string{SSL_TLS, OAUTH, XOAUTH, STARTTLS, INSECURE}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewAccountWizard() *AccountWizard {
|
||||||
|
wizard := &AccountWizard{
|
||||||
|
accountName: ui.NewTextInput("", config.Ui).Prompt("> "),
|
||||||
|
temporary: false,
|
||||||
|
email: ui.NewTextInput("", config.Ui).Prompt("> "),
|
||||||
|
fullName: ui.NewTextInput("", config.Ui).Prompt("> "),
|
||||||
|
sourcePassword: ui.NewTextInput("", config.Ui).Prompt("] ").Password(true),
|
||||||
|
sourceServer: ui.NewTextInput("", config.Ui).Prompt("> "),
|
||||||
|
sourceStr: ui.NewText("", config.Ui.GetStyle(config.STYLE_DEFAULT)),
|
||||||
|
sourceUsername: ui.NewTextInput("", config.Ui).Prompt("> "),
|
||||||
|
outgoingPassword: ui.NewTextInput("", config.Ui).Prompt("] ").Password(true),
|
||||||
|
outgoingServer: ui.NewTextInput("", config.Ui).Prompt("> "),
|
||||||
|
outgoingStr: ui.NewText("", config.Ui.GetStyle(config.STYLE_DEFAULT)),
|
||||||
|
outgoingUsername: ui.NewTextInput("", config.Ui).Prompt("> "),
|
||||||
|
outgoingCopyTo: ui.NewTextInput("", config.Ui).Prompt("> "),
|
||||||
|
|
||||||
|
sourceProtocol: NewSelector(sources, 0, config.Ui).Chooser(true),
|
||||||
|
sourceTransport: NewSelector(transports, 0, config.Ui).Chooser(true),
|
||||||
|
outgoingProtocol: NewSelector(outgoings, 0, config.Ui).Chooser(true),
|
||||||
|
outgoingTransport: NewSelector(transports, 0, config.Ui).Chooser(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autofill some stuff for the user
|
||||||
|
wizard.email.OnFocusLost(func(_ *ui.TextInput) {
|
||||||
|
value := wizard.email.String()
|
||||||
|
if wizard.sourceUsername.String() == "" {
|
||||||
|
wizard.sourceUsername.Set(value)
|
||||||
|
}
|
||||||
|
if wizard.outgoingUsername.String() == "" {
|
||||||
|
wizard.outgoingUsername.Set(value)
|
||||||
|
}
|
||||||
|
wizard.sourceUri()
|
||||||
|
wizard.outgoingUri()
|
||||||
|
})
|
||||||
|
wizard.sourceProtocol.OnSelect(func(option string) {
|
||||||
|
wizard.sourceServer.Set("")
|
||||||
|
wizard.autofill()
|
||||||
|
wizard.sourceUri()
|
||||||
|
})
|
||||||
|
wizard.sourceServer.OnChange(func(_ *ui.TextInput) {
|
||||||
|
wizard.sourceUri()
|
||||||
|
})
|
||||||
|
wizard.sourceServer.OnFocusLost(func(_ *ui.TextInput) {
|
||||||
|
src := wizard.sourceServer.String()
|
||||||
|
out := wizard.outgoingServer.String()
|
||||||
|
if out == "" && strings.HasPrefix(src, "imap.") {
|
||||||
|
out = strings.Replace(src, "imap.", "smtp.", 1)
|
||||||
|
wizard.outgoingServer.Set(out)
|
||||||
|
}
|
||||||
|
wizard.outgoingUri()
|
||||||
|
})
|
||||||
|
wizard.sourceUsername.OnChange(func(_ *ui.TextInput) {
|
||||||
|
wizard.sourceUri()
|
||||||
|
})
|
||||||
|
wizard.sourceUsername.OnFocusLost(func(_ *ui.TextInput) {
|
||||||
|
if wizard.outgoingUsername.String() == "" {
|
||||||
|
wizard.outgoingUsername.Set(wizard.sourceUsername.String())
|
||||||
|
wizard.outgoingUri()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
wizard.sourceTransport.OnSelect(func(option string) {
|
||||||
|
wizard.sourceUri()
|
||||||
|
})
|
||||||
|
var once sync.Once
|
||||||
|
wizard.sourcePassword.OnChange(func(_ *ui.TextInput) {
|
||||||
|
wizard.outgoingPassword.Set(wizard.sourcePassword.String())
|
||||||
|
wizard.sourceUri()
|
||||||
|
wizard.outgoingUri()
|
||||||
|
})
|
||||||
|
wizard.sourcePassword.OnFocusLost(func(_ *ui.TextInput) {
|
||||||
|
if wizard.sourcePassword.String() != "" {
|
||||||
|
once.Do(func() {
|
||||||
|
showPasswordWarning()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
wizard.outgoingProtocol.OnSelect(func(option string) {
|
||||||
|
wizard.outgoingServer.Set("")
|
||||||
|
wizard.autofill()
|
||||||
|
wizard.outgoingUri()
|
||||||
|
})
|
||||||
|
wizard.outgoingServer.OnChange(func(_ *ui.TextInput) {
|
||||||
|
wizard.outgoingUri()
|
||||||
|
})
|
||||||
|
wizard.outgoingUsername.OnChange(func(_ *ui.TextInput) {
|
||||||
|
wizard.outgoingUri()
|
||||||
|
})
|
||||||
|
wizard.outgoingPassword.OnChange(func(_ *ui.TextInput) {
|
||||||
|
if wizard.outgoingPassword.String() != "" {
|
||||||
|
once.Do(func() {
|
||||||
|
showPasswordWarning()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wizard.outgoingUri()
|
||||||
|
})
|
||||||
|
wizard.outgoingTransport.OnSelect(func(option string) {
|
||||||
|
wizard.outgoingUri()
|
||||||
|
})
|
||||||
|
|
||||||
|
// CONFIGURE_BASICS
|
||||||
|
basics := NewConfigStep(
|
||||||
|
`
|
||||||
|
Welcome to aerc! Let's configure your account.
|
||||||
|
|
||||||
|
Key bindings:
|
||||||
|
|
||||||
|
<Tab>, <Down> or <Ctrl+j> Next field
|
||||||
|
<Shift+Tab>, <Up> or <Ctrl+k> Previous field
|
||||||
|
<Ctrl+q> Exit aerc
|
||||||
|
`,
|
||||||
|
&wizard.basics,
|
||||||
|
)
|
||||||
|
basics.AddField(
|
||||||
|
"Name for this account? (e.g. 'Personal' or 'Work')",
|
||||||
|
wizard.accountName,
|
||||||
|
)
|
||||||
|
basics.AddField(
|
||||||
|
"Full name for outgoing emails? (e.g. 'John Doe')",
|
||||||
|
wizard.fullName,
|
||||||
|
)
|
||||||
|
basics.AddField(
|
||||||
|
"Your email address? (e.g. 'john@example.org')",
|
||||||
|
wizard.email,
|
||||||
|
)
|
||||||
|
basics.AddField("", NewSelector([]string{"Next"}, 0, config.Ui).
|
||||||
|
OnChoose(func(option string) {
|
||||||
|
wizard.discoverServices()
|
||||||
|
wizard.autofill()
|
||||||
|
wizard.sourceUri()
|
||||||
|
wizard.outgoingUri()
|
||||||
|
wizard.advance(option)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// CONFIGURE_SOURCE
|
||||||
|
source := NewConfigStep("Configure email source", &wizard.source)
|
||||||
|
source.AddField("Protocol", wizard.sourceProtocol)
|
||||||
|
source.AddField("Username", wizard.sourceUsername)
|
||||||
|
source.AddField("Password", wizard.sourcePassword)
|
||||||
|
source.AddField(
|
||||||
|
"Server address (or path to email store)",
|
||||||
|
wizard.sourceServer,
|
||||||
|
)
|
||||||
|
source.AddField("Transport security", wizard.sourceTransport)
|
||||||
|
source.AddField("Connection URL", wizard.sourceStr)
|
||||||
|
source.AddField(
|
||||||
|
"", NewSelector([]string{"Previous", "Next"}, 1, config.Ui).
|
||||||
|
OnChoose(wizard.advance),
|
||||||
|
)
|
||||||
|
|
||||||
|
// CONFIGURE_OUTGOING
|
||||||
|
outgoing := NewConfigStep("Configure outgoing mail", &wizard.outgoing)
|
||||||
|
outgoing.AddField("Protocol", wizard.outgoingProtocol)
|
||||||
|
outgoing.AddField("Username", wizard.outgoingUsername)
|
||||||
|
outgoing.AddField("Password", wizard.outgoingPassword)
|
||||||
|
outgoing.AddField(
|
||||||
|
"Server address (or path to sendmail)",
|
||||||
|
wizard.outgoingServer,
|
||||||
|
)
|
||||||
|
outgoing.AddField("Transport security", wizard.outgoingTransport)
|
||||||
|
outgoing.AddField("Connection URL", wizard.outgoingStr)
|
||||||
|
outgoing.AddField(
|
||||||
|
"Copy sent messages to folder (leave empty to disable)",
|
||||||
|
wizard.outgoingCopyTo,
|
||||||
|
)
|
||||||
|
outgoing.AddField(
|
||||||
|
"", NewSelector([]string{"Previous", "Next"}, 1, config.Ui).
|
||||||
|
OnChoose(wizard.advance),
|
||||||
|
)
|
||||||
|
|
||||||
|
// CONFIGURE_COMPLETE
|
||||||
|
complete := NewConfigStep(
|
||||||
|
fmt.Sprintf(`
|
||||||
|
Configuration complete!
|
||||||
|
|
||||||
|
You can go back and double check your settings, or choose [Finish] to
|
||||||
|
save your settings to %s/accounts.conf.
|
||||||
|
|
||||||
|
Make sure to review the contents of this file and read the
|
||||||
|
aerc-accounts(5) man page for guidance and further tweaking.
|
||||||
|
|
||||||
|
To add another account in the future, run ':new-account'.
|
||||||
|
`, xdg.TildeHome(xdg.ConfigPath("aerc"))),
|
||||||
|
&wizard.complete,
|
||||||
|
)
|
||||||
|
complete.AddField(
|
||||||
|
"", NewSelector([]string{
|
||||||
|
"Previous",
|
||||||
|
"Finish & open tutorial",
|
||||||
|
"Finish",
|
||||||
|
}, 1, config.Ui).OnChoose(func(option string) {
|
||||||
|
switch option {
|
||||||
|
case "Previous":
|
||||||
|
wizard.advance("Previous")
|
||||||
|
case "Finish & open tutorial":
|
||||||
|
wizard.finish(true)
|
||||||
|
case "Finish":
|
||||||
|
wizard.finish(false)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
wizard.steps = []*ui.Grid{
|
||||||
|
basics.Grid(), source.Grid(), outgoing.Grid(), complete.Grid(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return wizard
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wizard *AccountWizard) ConfigureTemporaryAccount(temporary bool) {
|
||||||
|
wizard.temporary = temporary
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wizard *AccountWizard) errorFor(d ui.Interactive, err error) {
|
||||||
|
if d == nil {
|
||||||
|
PushError(err.Error())
|
||||||
|
wizard.Invalidate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for step, interactives := range [][]ui.Interactive{
|
||||||
|
wizard.basics,
|
||||||
|
wizard.source,
|
||||||
|
wizard.outgoing,
|
||||||
|
} {
|
||||||
|
for focus, item := range interactives {
|
||||||
|
if item == d {
|
||||||
|
wizard.Focus(false)
|
||||||
|
wizard.step = step
|
||||||
|
wizard.focus = focus
|
||||||
|
wizard.Focus(true)
|
||||||
|
PushError(err.Error())
|
||||||
|
wizard.Invalidate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wizard *AccountWizard) finish(tutorial bool) {
|
||||||
|
accountsConf := xdg.ConfigPath("aerc", "accounts.conf")
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if wizard.accountName.String() == "" {
|
||||||
|
wizard.errorFor(wizard.accountName,
|
||||||
|
errors.New("Account name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if wizard.email.String() == "" {
|
||||||
|
wizard.errorFor(wizard.email,
|
||||||
|
errors.New("Email address is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if wizard.sourceServer.String() == "" {
|
||||||
|
wizard.errorFor(wizard.sourceServer,
|
||||||
|
errors.New("Email source configuration is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if wizard.outgoingServer.String() == "" &&
|
||||||
|
wizard.outgoingProtocol.Selected() != JMAP {
|
||||||
|
wizard.errorFor(wizard.outgoingServer,
|
||||||
|
errors.New("Outgoing mail configuration is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch wizard.sourceProtocol.Selected() {
|
||||||
|
case MAILDIR, MAILDIRPP, NOTMUCH:
|
||||||
|
path := xdg.ExpandHome(wizard.sourceServer.String())
|
||||||
|
s, err := os.Stat(path)
|
||||||
|
if err == nil && !s.IsDir() {
|
||||||
|
err = fmt.Errorf("%s: Not a directory", s.Name())
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = unix.Access(path, unix.X_OK)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
wizard.errorFor(wizard.sourceServer, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wizard.outgoingProtocol.Selected() == SENDMAIL {
|
||||||
|
path := xdg.ExpandHome(wizard.outgoingServer.String())
|
||||||
|
s, err := os.Stat(path)
|
||||||
|
if err == nil && !s.Mode().IsRegular() {
|
||||||
|
err = fmt.Errorf("%s: Not a regular file", s.Name())
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = unix.Access(path, unix.X_OK)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
wizard.errorFor(wizard.outgoingServer, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := ini.Load(accountsConf)
|
||||||
|
if err != nil {
|
||||||
|
file = ini.Empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
var sec *ini.Section
|
||||||
|
if sec, _ = file.GetSection(wizard.accountName.String()); sec != nil {
|
||||||
|
wizard.errorFor(wizard.accountName,
|
||||||
|
errors.New("An account by this name already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sec, _ = file.NewSection(wizard.accountName.String())
|
||||||
|
// these can't fail
|
||||||
|
_, _ = sec.NewKey("source", wizard.sourceUrl.String())
|
||||||
|
_, _ = sec.NewKey("outgoing", wizard.outgoingUrl.String())
|
||||||
|
_, _ = sec.NewKey("default", "INBOX")
|
||||||
|
from := mail.Address{
|
||||||
|
Name: wizard.fullName.String(),
|
||||||
|
Address: wizard.email.String(),
|
||||||
|
}
|
||||||
|
_, _ = sec.NewKey("from", format.AddressForHumans(&from))
|
||||||
|
if wizard.outgoingCopyTo.String() != "" {
|
||||||
|
_, _ = sec.NewKey("copy-to", wizard.outgoingCopyTo.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
switch wizard.sourceProtocol.Selected() {
|
||||||
|
case IMAP:
|
||||||
|
_, _ = sec.NewKey("cache-headers", "true")
|
||||||
|
case JMAP:
|
||||||
|
_, _ = sec.NewKey("use-labels", "true")
|
||||||
|
_, _ = sec.NewKey("cache-state", "true")
|
||||||
|
_, _ = sec.NewKey("cache-blobs", "false")
|
||||||
|
case NOTMUCH:
|
||||||
|
cmd := exec.Command("notmuch", "config", "get", "database.mail_root")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
root := strings.TrimSpace(string(out))
|
||||||
|
_, _ = sec.NewKey("maildir-store", xdg.TildeHome(root))
|
||||||
|
}
|
||||||
|
querymap := ini.Empty()
|
||||||
|
def := querymap.Section("")
|
||||||
|
cmd = exec.Command("notmuch", "config", "list")
|
||||||
|
out, err = cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
re := regexp.MustCompile(`(?m)^query\.([^=]+)=(.+)$`)
|
||||||
|
for _, m := range re.FindAllStringSubmatch(string(out), -1) {
|
||||||
|
_, _ = def.NewKey(m[1], m[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(def.Keys()) == 0 {
|
||||||
|
_, _ = def.NewKey("INBOX", "tag:inbox and not tag:archived")
|
||||||
|
}
|
||||||
|
if !wizard.temporary {
|
||||||
|
qmapPath := xdg.ConfigPath("aerc",
|
||||||
|
wizard.accountName.String()+".qmap")
|
||||||
|
f, err := os.OpenFile(qmapPath, os.O_WRONLY|os.O_CREATE, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
wizard.errorFor(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err = querymap.WriteTo(f); err != nil {
|
||||||
|
wizard.errorFor(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = sec.NewKey("query-map", xdg.TildeHome(qmapPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !wizard.temporary {
|
||||||
|
f, err := os.OpenFile(accountsConf, os.O_WRONLY|os.O_CREATE, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
wizard.errorFor(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err = file.WriteTo(f); err != nil {
|
||||||
|
wizard.errorFor(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := config.ParseAccountConfig(sec.Name(), sec)
|
||||||
|
if err != nil {
|
||||||
|
wizard.errorFor(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config.Accounts = append(config.Accounts, account)
|
||||||
|
|
||||||
|
view, err := NewAccountView(account, nil)
|
||||||
|
if err != nil {
|
||||||
|
NewTab(errorScreen(err.Error()), account.Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aerc.accounts[account.Name] = view
|
||||||
|
NewTab(view, account.Name)
|
||||||
|
|
||||||
|
if tutorial {
|
||||||
|
name := "aerc-tutorial"
|
||||||
|
if _, err := os.Stat("./aerc-tutorial.7"); !os.IsNotExist(err) {
|
||||||
|
// For development
|
||||||
|
name = "./aerc-tutorial.7"
|
||||||
|
}
|
||||||
|
term, err := NewTerminal(exec.Command("man", name))
|
||||||
|
if err != nil {
|
||||||
|
wizard.errorFor(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
NewTab(term, "Tutorial")
|
||||||
|
term.OnClose = func(err error) {
|
||||||
|
RemoveTab(term, false)
|
||||||
|
if err != nil {
|
||||||
|
PushError(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveTab(wizard, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitHostPath(server string) (string, string) {
|
||||||
|
host, path, found := strings.Cut(server, "/")
|
||||||
|
if found {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
return host, path
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeURLs(scheme, host, path, user, pass string) (url.URL, url.URL) {
|
||||||
|
var opaque string
|
||||||
|
|
||||||
|
// If everything is unset, the rendered URL is '<scheme>:'.
|
||||||
|
// Force a '//' opaque suffix so that it is rendered as '<scheme>://'.
|
||||||
|
if scheme != "" && host == "" && path == "" && user == "" && pass == "" {
|
||||||
|
opaque = "//"
|
||||||
|
}
|
||||||
|
|
||||||
|
uri := url.URL{Scheme: scheme, Host: host, Path: path, Opaque: opaque}
|
||||||
|
clean := uri
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case pass != "":
|
||||||
|
uri.User = url.UserPassword(user, pass)
|
||||||
|
clean.User = url.UserPassword(user, strings.Repeat("*", len(pass)))
|
||||||
|
case user != "":
|
||||||
|
uri.User = url.User(user)
|
||||||
|
clean.User = url.User(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri, clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wizard *AccountWizard) sourceUri() url.URL {
|
||||||
|
host, path := splitHostPath(wizard.sourceServer.String())
|
||||||
|
user := wizard.sourceUsername.String()
|
||||||
|
pass := wizard.sourcePassword.String()
|
||||||
|
var scheme string
|
||||||
|
switch wizard.sourceProtocol.Selected() {
|
||||||
|
case IMAP:
|
||||||
|
switch wizard.sourceTransport.Selected() {
|
||||||
|
case STARTTLS:
|
||||||
|
scheme = "imap"
|
||||||
|
case INSECURE:
|
||||||
|
scheme = "imap+insecure"
|
||||||
|
case OAUTH:
|
||||||
|
scheme = "imaps+oauthbearer"
|
||||||
|
case XOAUTH:
|
||||||
|
scheme = "imaps+xoauth2"
|
||||||
|
default:
|
||||||
|
scheme = "imaps"
|
||||||
|
}
|
||||||
|
case JMAP:
|
||||||
|
switch wizard.sourceTransport.Selected() {
|
||||||
|
case OAUTH:
|
||||||
|
scheme = "jmap+oauthbearer"
|
||||||
|
default:
|
||||||
|
scheme = "jmap"
|
||||||
|
}
|
||||||
|
case MAILDIR:
|
||||||
|
scheme = "maildir"
|
||||||
|
case MAILDIRPP:
|
||||||
|
scheme = "maildirpp"
|
||||||
|
case NOTMUCH:
|
||||||
|
scheme = "notmuch"
|
||||||
|
}
|
||||||
|
switch wizard.sourceProtocol.Selected() {
|
||||||
|
case MAILDIR, MAILDIRPP, NOTMUCH:
|
||||||
|
path = host + path
|
||||||
|
host = ""
|
||||||
|
user = ""
|
||||||
|
pass = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
uri, clean := makeURLs(scheme, host, path, user, pass)
|
||||||
|
|
||||||
|
wizard.sourceStr.Text(
|
||||||
|
" " + strings.ReplaceAll(clean.String(), "%2A", "*"))
|
||||||
|
wizard.sourceUrl = uri
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wizard *AccountWizard) outgoingUri() url.URL {
|
||||||
|
host, path := splitHostPath(wizard.outgoingServer.String())
|
||||||
|
user := wizard.outgoingUsername.String()
|
||||||
|
pass := wizard.outgoingPassword.String()
|
||||||
|
var scheme string
|
||||||
|
switch wizard.outgoingProtocol.Selected() {
|
||||||
|
case SMTP:
|
||||||
|
switch wizard.outgoingTransport.Selected() {
|
||||||
|
case OAUTH:
|
||||||
|
scheme = "smtps+oauthbearer"
|
||||||
|
case XOAUTH:
|
||||||
|
scheme = "smtps+xoauth2"
|
||||||
|
case INSECURE:
|
||||||
|
scheme = "smtp+insecure"
|
||||||
|
case STARTTLS:
|
||||||
|
scheme = "smtp"
|
||||||
|
default:
|
||||||
|
scheme = "smtps"
|
||||||
|
}
|
||||||
|
case JMAP:
|
||||||
|
switch wizard.outgoingTransport.Selected() {
|
||||||
|
case OAUTH:
|
||||||
|
scheme = "jmap+oauthbearer"
|
||||||
|
default:
|
||||||
|
scheme = "jmap"
|
||||||
|
}
|
||||||
|
case SENDMAIL:
|
||||||
|
scheme = ""
|
||||||
|
path = host + path
|
||||||
|
host = ""
|
||||||
|
user = ""
|
||||||
|
pass = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
uri, clean := makeURLs(scheme, host, path, user, pass)
|
||||||
|
|
||||||
|
wizard.outgoingStr.Text(
|
||||||
|
" " + strings.ReplaceAll(clean.String(), "%2A", "*"))
|
||||||
|
wizard.outgoingUrl = uri
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wizard *AccountWizard) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wizard *AccountWizard) Draw(ctx *ui.Context) {
|
||||||
|
wizard.steps[wizard.step].Draw(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wizard *AccountWizard) getInteractive() []ui.Interactive {
|
||||||
|
switch wizard.step {
|
||||||
|
case CONFIGURE_BASICS:
|
||||||
|
return wizard.basics
|
||||||
|
case CONFIGURE_SOURCE:
|
||||||
|
return wizard.source
|
||||||
|
case CONFIGURE_OUTGOING:
|
||||||
|
return wizard.outgoing
|
||||||
|
case CONFIGURE_COMPLETE:
|
||||||
|
return wizard.complete
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wizard *AccountWizard) advance(direction string) {
|
||||||
|
wizard.Focus(false)
|
||||||
|
if direction == "Next" && wizard.step < len(wizard.steps)-1 {
|
||||||
|
wizard.step++
|
||||||
|
}
|
||||||
|
if direction == "Previous" && wizard.step > 0 {
|
||||||
|
wizard.step--
|
||||||
|
}
|
||||||
|
wizard.focus = 0
|
||||||
|
wizard.Focus(true)
|
||||||
|
wizard.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wizard *AccountWizard) Focus(focus bool) {
|
||||||
|
if interactive := wizard.getInteractive(); interactive != nil {
|
||||||
|
interactive[wizard.focus].Focus(focus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wizard *AccountWizard) Event(event vaxis.Event) bool {
|
||||||
|
interactive := wizard.getInteractive()
|
||||||
|
if key, ok := event.(vaxis.Key); ok {
|
||||||
|
switch {
|
||||||
|
case key.Matches('k', vaxis.ModCtrl),
|
||||||
|
key.Matches(vaxis.KeyTab, vaxis.ModShift),
|
||||||
|
key.Matches(vaxis.KeyUp):
|
||||||
|
if interactive != nil {
|
||||||
|
interactive[wizard.focus].Focus(false)
|
||||||
|
wizard.focus--
|
||||||
|
if wizard.focus < 0 {
|
||||||
|
wizard.focus = len(interactive) - 1
|
||||||
|
}
|
||||||
|
interactive[wizard.focus].Focus(true)
|
||||||
|
}
|
||||||
|
wizard.Invalidate()
|
||||||
|
return true
|
||||||
|
case key.Matches('j', vaxis.ModCtrl),
|
||||||
|
key.Matches(vaxis.KeyTab),
|
||||||
|
key.Matches(vaxis.KeyDown):
|
||||||
|
|
||||||
|
if interactive != nil {
|
||||||
|
interactive[wizard.focus].Focus(false)
|
||||||
|
wizard.focus++
|
||||||
|
if wizard.focus >= len(interactive) {
|
||||||
|
wizard.focus = 0
|
||||||
|
}
|
||||||
|
interactive[wizard.focus].Focus(true)
|
||||||
|
}
|
||||||
|
wizard.Invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if interactive != nil {
|
||||||
|
return interactive[wizard.focus].Event(event)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wizard *AccountWizard) discoverServices() {
|
||||||
|
email := wizard.email.String()
|
||||||
|
if !strings.ContainsRune(email, '@') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domain := email[strings.IndexRune(email, '@')+1:]
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
type Service struct{ srv, hostport string }
|
||||||
|
services := make(chan Service)
|
||||||
|
|
||||||
|
for _, service := range []string{"imaps", "imap", "submission", "jmap"} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(srv string) {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
defer wg.Done()
|
||||||
|
_, addrs, err := net.LookupSRV(srv, "tcp", domain)
|
||||||
|
if err != nil {
|
||||||
|
log.Tracef("SRV lookup for _%s._tcp.%s failed: %s",
|
||||||
|
srv, domain, err)
|
||||||
|
} else if addrs[0].Target != "" && addrs[0].Port > 0 {
|
||||||
|
services <- Service{
|
||||||
|
srv: srv,
|
||||||
|
hostport: net.JoinHostPort(
|
||||||
|
strings.TrimSuffix(addrs[0].Target, "."),
|
||||||
|
strconv.Itoa(int(addrs[0].Port))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(service)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
wg.Wait()
|
||||||
|
close(services)
|
||||||
|
}()
|
||||||
|
|
||||||
|
wizard.discovered = make(map[string]string)
|
||||||
|
for s := range services {
|
||||||
|
wizard.discovered[s.srv] = s.hostport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wizard *AccountWizard) autofill() {
|
||||||
|
if wizard.sourceServer.String() == "" {
|
||||||
|
switch wizard.sourceProtocol.Selected() {
|
||||||
|
case IMAP:
|
||||||
|
if s, ok := wizard.discovered["imaps"]; ok {
|
||||||
|
wizard.sourceServer.Set(s)
|
||||||
|
wizard.sourceTransport.Select(SSL_TLS)
|
||||||
|
} else if s, ok := wizard.discovered["imap"]; ok {
|
||||||
|
wizard.sourceServer.Set(s)
|
||||||
|
wizard.sourceTransport.Select(STARTTLS)
|
||||||
|
}
|
||||||
|
case JMAP:
|
||||||
|
if s, ok := wizard.discovered["jmap"]; ok {
|
||||||
|
s = strings.TrimSuffix(s, ":443")
|
||||||
|
wizard.sourceServer.Set(s + "/.well-known/jmap")
|
||||||
|
wizard.sourceTransport.Select(SSL_TLS)
|
||||||
|
}
|
||||||
|
case MAILDIR, MAILDIRPP:
|
||||||
|
wizard.sourceServer.Set("~/mail")
|
||||||
|
wizard.sourceUsername.Set("")
|
||||||
|
wizard.sourcePassword.Set("")
|
||||||
|
case NOTMUCH:
|
||||||
|
cmd := exec.Command("notmuch", "config", "get", "database.path")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
db := strings.TrimSpace(string(out))
|
||||||
|
wizard.sourceServer.Set(xdg.TildeHome(db))
|
||||||
|
} else {
|
||||||
|
wizard.sourceServer.Set("~/mail")
|
||||||
|
}
|
||||||
|
wizard.sourceUsername.Set("")
|
||||||
|
wizard.sourcePassword.Set("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wizard.outgoingServer.String() == "" {
|
||||||
|
switch wizard.outgoingProtocol.Selected() {
|
||||||
|
case SMTP:
|
||||||
|
if s, ok := wizard.discovered["submission"]; ok {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(s, ":587"):
|
||||||
|
wizard.outgoingTransport.Select(SSL_TLS)
|
||||||
|
case strings.HasSuffix(s, ":465"):
|
||||||
|
wizard.outgoingTransport.Select(STARTTLS)
|
||||||
|
default:
|
||||||
|
wizard.outgoingTransport.Select(INSECURE)
|
||||||
|
}
|
||||||
|
wizard.outgoingServer.Set(s)
|
||||||
|
}
|
||||||
|
case JMAP:
|
||||||
|
wizard.outgoingTransport.Select(SSL_TLS)
|
||||||
|
case SENDMAIL:
|
||||||
|
wizard.outgoingServer.Set("/usr/sbin/sendmail")
|
||||||
|
wizard.outgoingUsername.Set("")
|
||||||
|
wizard.outgoingPassword.Set("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+776
@@ -0,0 +1,776 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/hooks"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/marker"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/pama"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/sort"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/templates"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ ProvidesMessages = (*AccountView)(nil)
|
||||||
|
|
||||||
|
type AccountView struct {
|
||||||
|
sync.Mutex
|
||||||
|
acct *config.AccountConfig
|
||||||
|
dirlist DirectoryLister
|
||||||
|
labels []string
|
||||||
|
grid *ui.Grid
|
||||||
|
tab *ui.Tab
|
||||||
|
msglist *MessageList
|
||||||
|
worker *types.Worker
|
||||||
|
state state.AccountState
|
||||||
|
newConn bool // True if this is a first run after a new connection/reconnection
|
||||||
|
|
||||||
|
split *MessageViewer
|
||||||
|
splitSize int
|
||||||
|
splitDebounce *time.Timer
|
||||||
|
splitDir config.SplitDirection
|
||||||
|
splitLoaded bool
|
||||||
|
|
||||||
|
// Check-mail ticker
|
||||||
|
ticker *time.Ticker
|
||||||
|
checkingMail bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) UiConfig() *config.UIConfig {
|
||||||
|
if dirlist := acct.Directories(); dirlist != nil {
|
||||||
|
return dirlist.UiConfig("")
|
||||||
|
}
|
||||||
|
return config.Ui.ForAccount(acct.acct.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAccountView(
|
||||||
|
acct *config.AccountConfig, deferLoop chan struct{},
|
||||||
|
) (*AccountView, error) {
|
||||||
|
view := &AccountView{
|
||||||
|
acct: acct,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker, err := worker.NewWorker(acct.Source, acct.Name)
|
||||||
|
if err != nil {
|
||||||
|
SetError(fmt.Sprintf("%s: %s", acct.Name, err))
|
||||||
|
log.Errorf("%s: %v", acct.Name, err)
|
||||||
|
return view, err
|
||||||
|
}
|
||||||
|
view.worker = worker
|
||||||
|
|
||||||
|
view.dirlist = NewDirectoryList(acct, worker)
|
||||||
|
|
||||||
|
view.msglist = NewMessageList(view)
|
||||||
|
|
||||||
|
view.Configure()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
|
||||||
|
if deferLoop != nil {
|
||||||
|
<-deferLoop
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.Backend.Run()
|
||||||
|
}()
|
||||||
|
|
||||||
|
worker.PostAction(&types.Configure{Config: acct}, nil)
|
||||||
|
worker.PostAction(&types.Connect{}, nil)
|
||||||
|
view.SetStatus(state.ConnectionActivity("Connecting..."))
|
||||||
|
if acct.CheckMail.Minutes() > 0 {
|
||||||
|
view.CheckMailTimer(acct.CheckMail)
|
||||||
|
}
|
||||||
|
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) Configure() {
|
||||||
|
acct.dirlist.OnVirtualNode(func() {
|
||||||
|
acct.msglist.SetStore(nil)
|
||||||
|
acct.Invalidate()
|
||||||
|
})
|
||||||
|
sidebar := acct.UiConfig().SidebarWidth
|
||||||
|
acct.grid = ui.NewGrid().Rows([]ui.GridSpec{
|
||||||
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
||||||
|
}).Columns([]ui.GridSpec{
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: func() int {
|
||||||
|
return sidebar
|
||||||
|
}},
|
||||||
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
||||||
|
})
|
||||||
|
if sidebar > 0 {
|
||||||
|
acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.UiConfig()))
|
||||||
|
}
|
||||||
|
acct.grid.AddChild(acct.msglist).At(0, 1)
|
||||||
|
acct.setTitle()
|
||||||
|
|
||||||
|
// handle splits
|
||||||
|
if acct.split != nil {
|
||||||
|
acct.split.Close()
|
||||||
|
}
|
||||||
|
splitDirection := acct.splitDir
|
||||||
|
acct.splitDir = config.SPLIT_NONE
|
||||||
|
switch splitDirection {
|
||||||
|
case config.SPLIT_HORIZONTAL:
|
||||||
|
acct.Split(acct.SplitSize())
|
||||||
|
case config.SPLIT_VERTICAL:
|
||||||
|
acct.Vsplit(acct.SplitSize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) SetStatus(setters ...state.SetStateFunc) {
|
||||||
|
for _, fn := range setters {
|
||||||
|
fn(&acct.state, acct.SelectedDirectory())
|
||||||
|
}
|
||||||
|
acct.UpdateStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) UpdateStatus() {
|
||||||
|
if acct.isSelected() {
|
||||||
|
UpdateStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) Select() {
|
||||||
|
for i, widget := range aerc.tabs.TabContent.Children() {
|
||||||
|
if widget == acct {
|
||||||
|
aerc.SelectTabIndex(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) PushStatus(status string, expiry time.Duration) {
|
||||||
|
PushStatus(fmt.Sprintf("%s: %s", acct.acct.Name, status), expiry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) PushError(err error) {
|
||||||
|
PushError(fmt.Sprintf("%s: %v", acct.acct.Name, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) PushWarning(warning string) {
|
||||||
|
PushWarning(fmt.Sprintf("%s: %s", acct.acct.Name, warning))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) AccountConfig() *config.AccountConfig {
|
||||||
|
return acct.acct
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) Worker() *types.Worker {
|
||||||
|
return acct.worker
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) Name() string {
|
||||||
|
return acct.acct.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) Draw(ctx *ui.Context) {
|
||||||
|
acct.grid.Draw(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) MouseEvent(localX int, localY int, event vaxis.Event) {
|
||||||
|
acct.grid.MouseEvent(localX, localY, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) Focus(focus bool) {
|
||||||
|
// TODO: Unfocus children I guess
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) Directories() DirectoryLister {
|
||||||
|
return acct.dirlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) SetDirectories(d DirectoryLister) {
|
||||||
|
if acct.grid != nil {
|
||||||
|
acct.grid.ReplaceChild(acct.dirlist, d)
|
||||||
|
}
|
||||||
|
acct.dirlist = d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) Labels() []string {
|
||||||
|
return acct.labels
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) Messages() *MessageList {
|
||||||
|
return acct.msglist
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) Store() *lib.MessageStore {
|
||||||
|
if acct.msglist == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return acct.msglist.Store()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) SelectedAccount() *AccountView {
|
||||||
|
return acct
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) SelectedDirectory() string {
|
||||||
|
return acct.dirlist.Selected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) SelectedMessage() (*models.MessageInfo, error) {
|
||||||
|
if acct.msglist == nil || acct.msglist.Store() == nil {
|
||||||
|
return nil, errors.New("init in progress")
|
||||||
|
}
|
||||||
|
if len(acct.msglist.Store().Uids()) == 0 {
|
||||||
|
return nil, errors.New("no message selected")
|
||||||
|
}
|
||||||
|
msg := acct.msglist.Selected()
|
||||||
|
if msg == nil {
|
||||||
|
return nil, errors.New("message not loaded")
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) MarkedMessages() ([]models.UID, error) {
|
||||||
|
if store := acct.Store(); store != nil {
|
||||||
|
return store.Marker().Marked(), nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("no store available")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) SelectedMessagePart() *PartInfo {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) Terminal() *Terminal {
|
||||||
|
if acct.split == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return acct.split.Terminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) isSelected() bool {
|
||||||
|
return acct == SelectedAccount()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) newStore(name string) *lib.MessageStore {
|
||||||
|
uiConf := acct.dirlist.UiConfig(name)
|
||||||
|
dir := acct.dirlist.Directory(name)
|
||||||
|
role := ""
|
||||||
|
if dir != nil {
|
||||||
|
role = string(dir.Role)
|
||||||
|
}
|
||||||
|
backend := acct.AccountConfig().Backend
|
||||||
|
store := lib.NewMessageStore(acct.worker, name,
|
||||||
|
func() *config.UIConfig {
|
||||||
|
return config.Ui.
|
||||||
|
ForAccount(acct.Name()).
|
||||||
|
ForFolder(name)
|
||||||
|
},
|
||||||
|
func(msg *models.MessageInfo) {
|
||||||
|
err := hooks.RunHook(&hooks.MailReceived{
|
||||||
|
Account: acct.Name(),
|
||||||
|
Backend: backend,
|
||||||
|
Folder: name,
|
||||||
|
Role: role,
|
||||||
|
MsgInfo: msg,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("mail-received hook: %s", err)
|
||||||
|
PushError(msg)
|
||||||
|
}
|
||||||
|
}, func() {
|
||||||
|
if uiConf.NewMessageBell {
|
||||||
|
aerc.Beep()
|
||||||
|
}
|
||||||
|
}, func() {
|
||||||
|
err := hooks.RunHook(&hooks.MailDeleted{
|
||||||
|
Account: acct.Name(),
|
||||||
|
Backend: backend,
|
||||||
|
Folder: name,
|
||||||
|
Role: role,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("mail-deleted hook: %s", err)
|
||||||
|
PushError(msg)
|
||||||
|
}
|
||||||
|
}, func(dest string) {
|
||||||
|
err := hooks.RunHook(&hooks.MailAdded{
|
||||||
|
Account: acct.Name(),
|
||||||
|
Backend: backend,
|
||||||
|
Folder: dest,
|
||||||
|
Role: role,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("mail-added hook: %s", err)
|
||||||
|
PushError(msg)
|
||||||
|
}
|
||||||
|
}, func(add []string, remove []string) {
|
||||||
|
err := hooks.RunHook(&hooks.TagModified{
|
||||||
|
Account: acct.Name(),
|
||||||
|
Backend: backend,
|
||||||
|
Add: add,
|
||||||
|
Remove: remove,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("tag-modified hook: %s", err)
|
||||||
|
PushError(msg)
|
||||||
|
}
|
||||||
|
}, func(flagname string) {
|
||||||
|
err := hooks.RunHook(&hooks.FlagChanged{
|
||||||
|
Account: acct.Name(),
|
||||||
|
Backend: backend,
|
||||||
|
Folder: acct.SelectedDirectory(),
|
||||||
|
Role: role,
|
||||||
|
FlagName: flagname,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("flag-changed hook: %s", err)
|
||||||
|
PushError(msg)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
func(msg *models.MessageInfo) {
|
||||||
|
acct.updateSplitView(msg)
|
||||||
|
|
||||||
|
auto := false
|
||||||
|
if c := acct.AccountConfig(); c != nil {
|
||||||
|
r, ok := c.Params["pama-auto-switch"]
|
||||||
|
if ok {
|
||||||
|
if strings.ToLower(r) == "true" {
|
||||||
|
auto = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !auto {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
if msg != nil && msg.Envelope != nil {
|
||||||
|
name = pama.FromSubject(msg.Envelope.Subject)
|
||||||
|
}
|
||||||
|
pama.DebouncedSwitchProject(name)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
store.Configure(acct.SortCriteria(uiConf))
|
||||||
|
store.SetMarker(marker.New(store))
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) onMessage(msg types.WorkerMessage) {
|
||||||
|
msg = acct.worker.ProcessMessage(msg)
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
switch resp := msg.InResponseTo().(type) {
|
||||||
|
case *types.Connect, *types.Reconnect:
|
||||||
|
acct.SetStatus(state.ConnectionActivity("Listing mailboxes..."))
|
||||||
|
log.Infof("[%s] connected.", acct.acct.Name)
|
||||||
|
acct.SetStatus(state.SetConnected(true))
|
||||||
|
log.Tracef("Listing mailboxes...")
|
||||||
|
acct.worker.PostAction(&types.ListDirectories{}, nil)
|
||||||
|
case *types.Disconnect:
|
||||||
|
acct.dirlist.ClearList()
|
||||||
|
acct.msglist.SetStore(nil)
|
||||||
|
log.Infof("[%s] disconnected.", acct.acct.Name)
|
||||||
|
acct.SetStatus(state.SetConnected(false))
|
||||||
|
case *types.OpenDirectory:
|
||||||
|
acct.dirlist.Update(msg)
|
||||||
|
if store, ok := acct.dirlist.SelectedMsgStore(); ok {
|
||||||
|
// If we've opened this dir before, we can re-render it from
|
||||||
|
// memory while we wait for the update and the UI feels
|
||||||
|
// snappier. If not, we'll unset the store and show the spinner
|
||||||
|
// while we download the UID list.
|
||||||
|
acct.msglist.SetStore(store)
|
||||||
|
acct.Store().Update(msg.InResponseTo())
|
||||||
|
} else {
|
||||||
|
acct.msglist.SetStore(nil)
|
||||||
|
}
|
||||||
|
case *types.CreateDirectory:
|
||||||
|
store := acct.newStore(resp.Directory)
|
||||||
|
acct.dirlist.SetMsgStore(&models.Directory{
|
||||||
|
Name: resp.Directory,
|
||||||
|
}, store)
|
||||||
|
acct.dirlist.Update(msg)
|
||||||
|
case *types.RemoveDirectory:
|
||||||
|
acct.dirlist.Update(msg)
|
||||||
|
case *types.FetchMessageHeaders:
|
||||||
|
if acct.newConn {
|
||||||
|
acct.checkMailOnStartup()
|
||||||
|
}
|
||||||
|
case *types.ListDirectories:
|
||||||
|
acct.dirlist.Update(msg)
|
||||||
|
if dir := acct.dirlist.Selected(); dir != "" {
|
||||||
|
acct.dirlist.Select(dir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Nothing selected, select based on config
|
||||||
|
dirs := acct.dirlist.List()
|
||||||
|
var dir string
|
||||||
|
for _, _dir := range dirs {
|
||||||
|
if _dir == acct.acct.Default {
|
||||||
|
dir = _dir
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dir == "" && len(dirs) > 0 {
|
||||||
|
dir = dirs[0]
|
||||||
|
}
|
||||||
|
if dir != "" {
|
||||||
|
acct.dirlist.Select(dir)
|
||||||
|
}
|
||||||
|
acct.msglist.SetInitDone()
|
||||||
|
acct.newConn = true
|
||||||
|
}
|
||||||
|
case *types.Directory:
|
||||||
|
store, ok := acct.dirlist.MsgStore(msg.Dir.Name)
|
||||||
|
if !ok {
|
||||||
|
store = acct.newStore(msg.Dir.Name)
|
||||||
|
}
|
||||||
|
acct.dirlist.SetMsgStore(msg.Dir, store)
|
||||||
|
case *types.DirectoryInfo:
|
||||||
|
acct.dirlist.Update(msg)
|
||||||
|
case *types.DirectoryContents:
|
||||||
|
if store, ok := acct.dirlist.SelectedMsgStore(); ok {
|
||||||
|
if acct.msglist.Store() == nil {
|
||||||
|
acct.msglist.SetStore(store)
|
||||||
|
}
|
||||||
|
store.Update(msg)
|
||||||
|
acct.SetStatus(state.Threading(store.ThreadedView()))
|
||||||
|
}
|
||||||
|
if acct.newConn && len(msg.Uids) == 0 {
|
||||||
|
acct.checkMailOnStartup()
|
||||||
|
}
|
||||||
|
case *types.DirectoryThreaded:
|
||||||
|
if store, ok := acct.dirlist.SelectedMsgStore(); ok {
|
||||||
|
if acct.msglist.Store() == nil {
|
||||||
|
acct.msglist.SetStore(store)
|
||||||
|
}
|
||||||
|
store.Update(msg)
|
||||||
|
acct.SetStatus(state.Threading(store.ThreadedView()))
|
||||||
|
}
|
||||||
|
if acct.newConn && len(msg.Threads) == 0 {
|
||||||
|
acct.checkMailOnStartup()
|
||||||
|
}
|
||||||
|
case *types.FullMessage:
|
||||||
|
if store, ok := acct.dirlist.SelectedMsgStore(); ok {
|
||||||
|
store.Update(msg)
|
||||||
|
}
|
||||||
|
case *types.MessageInfo:
|
||||||
|
if store, ok := acct.dirlist.SelectedMsgStore(); ok {
|
||||||
|
store.Update(msg)
|
||||||
|
}
|
||||||
|
case *types.MessagesDeleted:
|
||||||
|
if dir := acct.dirlist.SelectedDirectory(); dir != nil {
|
||||||
|
dir.Exists -= len(msg.Uids)
|
||||||
|
}
|
||||||
|
if store, ok := acct.dirlist.SelectedMsgStore(); ok {
|
||||||
|
store.Update(msg)
|
||||||
|
}
|
||||||
|
case *types.MessagesCopied:
|
||||||
|
acct.updateDirCounts(msg.Destination, msg.Uids)
|
||||||
|
case *types.MessagesMoved:
|
||||||
|
acct.updateDirCounts(msg.Destination, msg.Uids)
|
||||||
|
case *types.LabelList:
|
||||||
|
acct.labels = msg.Labels
|
||||||
|
case *types.ConnError:
|
||||||
|
log.Errorf("[%s] connection error: %v", acct.acct.Name, msg.Error)
|
||||||
|
acct.SetStatus(state.SetConnected(false))
|
||||||
|
acct.PushError(msg.Error)
|
||||||
|
acct.msglist.SetStore(nil)
|
||||||
|
acct.worker.PostAction(&types.Reconnect{}, nil)
|
||||||
|
case *types.Error:
|
||||||
|
log.Errorf("[%s] unexpected error: %v", acct.acct.Name, msg.Error)
|
||||||
|
acct.PushError(msg.Error)
|
||||||
|
}
|
||||||
|
acct.UpdateStatus()
|
||||||
|
acct.setTitle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) updateDirCounts(destination string, uids []models.UID) {
|
||||||
|
// Only update the destination destDir if it is initialized
|
||||||
|
if destDir := acct.dirlist.Directory(destination); destDir != nil {
|
||||||
|
var recent, unseen int
|
||||||
|
var accurate bool = true
|
||||||
|
for _, uid := range uids {
|
||||||
|
// Get the message from the originating store
|
||||||
|
msg, ok := acct.Store().Messages[uid]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// If message that was not yet loaded is copied
|
||||||
|
if msg == nil {
|
||||||
|
accurate = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
seen := msg.Flags.Has(models.SeenFlag)
|
||||||
|
if msg.Flags.Has(models.RecentFlag) {
|
||||||
|
recent++
|
||||||
|
}
|
||||||
|
if !seen {
|
||||||
|
unseen++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accurate {
|
||||||
|
destDir.Recent += recent
|
||||||
|
destDir.Unseen += unseen
|
||||||
|
destDir.Exists += len(uids)
|
||||||
|
} else {
|
||||||
|
destDir.Exists += len(uids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) SortCriteria(uiConf *config.UIConfig) []*types.SortCriterion {
|
||||||
|
if uiConf == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(uiConf.Sort) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
criteria, err := sort.GetSortCriteria(uiConf.Sort)
|
||||||
|
if err != nil {
|
||||||
|
acct.PushError(fmt.Errorf("ui sort: %w", err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return criteria
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) GetSortCriteria() []*types.SortCriterion {
|
||||||
|
return acct.SortCriteria(acct.UiConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) CheckMail() {
|
||||||
|
acct.Lock()
|
||||||
|
defer acct.Unlock()
|
||||||
|
if acct.checkingMail {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Exclude selected mailbox, per IMAP specification
|
||||||
|
exclude := append(acct.AccountConfig().CheckMailExclude, acct.dirlist.Selected()) //nolint:gocritic // intentional append to different slice
|
||||||
|
dirs := acct.dirlist.List()
|
||||||
|
dirs = acct.dirlist.FilterDirs(dirs, acct.AccountConfig().CheckMailInclude, false)
|
||||||
|
dirs = acct.dirlist.FilterDirs(dirs, exclude, true)
|
||||||
|
log.Debugf("Checking for new mail on account %s", acct.Name())
|
||||||
|
acct.SetStatus(state.ConnectionActivity("Checking for new mail..."))
|
||||||
|
msg := &types.CheckMail{
|
||||||
|
Directories: dirs,
|
||||||
|
Command: acct.acct.CheckMailCmd,
|
||||||
|
Timeout: acct.acct.CheckMailTimeout,
|
||||||
|
}
|
||||||
|
acct.checkingMail = true
|
||||||
|
|
||||||
|
var cb func(types.WorkerMessage)
|
||||||
|
cb = func(response types.WorkerMessage) {
|
||||||
|
dirsMsg, ok := response.(*types.CheckMailDirectories)
|
||||||
|
if ok {
|
||||||
|
checkMailMsg := &types.CheckMail{
|
||||||
|
Directories: dirsMsg.Directories,
|
||||||
|
Command: acct.acct.CheckMailCmd,
|
||||||
|
Timeout: acct.acct.CheckMailTimeout,
|
||||||
|
}
|
||||||
|
acct.worker.PostAction(checkMailMsg, cb)
|
||||||
|
} else { // Done
|
||||||
|
acct.SetStatus(state.ConnectionActivity(""))
|
||||||
|
acct.Lock()
|
||||||
|
acct.checkingMail = false
|
||||||
|
acct.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acct.worker.PostAction(msg, cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckMailReset resets the check-mail timer
|
||||||
|
func (acct *AccountView) CheckMailReset() {
|
||||||
|
if acct.ticker != nil {
|
||||||
|
d := acct.AccountConfig().CheckMail
|
||||||
|
acct.ticker = time.NewTicker(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) checkMailOnStartup() {
|
||||||
|
if acct.AccountConfig().CheckMail.Minutes() > 0 {
|
||||||
|
acct.newConn = false
|
||||||
|
acct.CheckMail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) CheckMailTimer(d time.Duration) {
|
||||||
|
acct.ticker = time.NewTicker(d)
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
for range acct.ticker.C {
|
||||||
|
if !acct.state.Connected {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acct.CheckMail()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) closeSplit() {
|
||||||
|
if acct.split != nil {
|
||||||
|
acct.split.Close()
|
||||||
|
}
|
||||||
|
acct.splitSize = 0
|
||||||
|
acct.splitDir = config.SPLIT_NONE
|
||||||
|
acct.split = nil
|
||||||
|
acct.grid = ui.NewGrid().Rows([]ui.GridSpec{
|
||||||
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
||||||
|
}).Columns([]ui.GridSpec{
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: func() int {
|
||||||
|
return acct.UiConfig().SidebarWidth
|
||||||
|
}},
|
||||||
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
||||||
|
})
|
||||||
|
|
||||||
|
acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.UiConfig()))
|
||||||
|
acct.grid.AddChild(acct.msglist).At(0, 1)
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) updateSplitView(msg *models.MessageInfo) {
|
||||||
|
uiConf := acct.UiConfig()
|
||||||
|
if !acct.splitLoaded {
|
||||||
|
switch uiConf.MessageListSplit.Direction {
|
||||||
|
case config.SPLIT_HORIZONTAL:
|
||||||
|
acct.Split(uiConf.MessageListSplit.Size)
|
||||||
|
case config.SPLIT_VERTICAL:
|
||||||
|
acct.Vsplit(uiConf.MessageListSplit.Size)
|
||||||
|
}
|
||||||
|
acct.splitLoaded = true
|
||||||
|
}
|
||||||
|
if acct.splitSize == 0 || !acct.splitLoaded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if acct.splitDebounce != nil {
|
||||||
|
acct.splitDebounce.Stop()
|
||||||
|
}
|
||||||
|
fn := func() {
|
||||||
|
if acct.split != nil {
|
||||||
|
acct.grid.RemoveChild(acct.split)
|
||||||
|
acct.split.Close()
|
||||||
|
}
|
||||||
|
lib.NewMessageStoreView(msg, false, acct.Store(), CryptoProvider(), DecryptKeys,
|
||||||
|
func(view lib.MessageView, err error) {
|
||||||
|
if err != nil {
|
||||||
|
PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewer, err := NewMessageViewer(acct, view)
|
||||||
|
if err != nil {
|
||||||
|
PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acct.split = viewer
|
||||||
|
switch acct.splitDir {
|
||||||
|
case config.SPLIT_HORIZONTAL:
|
||||||
|
acct.grid.AddChild(acct.split).At(1, 1)
|
||||||
|
case config.SPLIT_VERTICAL:
|
||||||
|
acct.grid.AddChild(acct.split).At(0, 2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
acct.splitDebounce = time.AfterFunc(100*time.Millisecond, func() {
|
||||||
|
ui.QueueFunc(fn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) SplitSize() int {
|
||||||
|
return acct.splitSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acct *AccountView) SetSplitSize(n int) {
|
||||||
|
if n == 0 {
|
||||||
|
acct.closeSplit()
|
||||||
|
}
|
||||||
|
acct.splitSize = n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split splits the message list view horizontally. The message list will be n
|
||||||
|
// rows high. If n is 0, any existing split is removed
|
||||||
|
func (acct *AccountView) Split(n int) {
|
||||||
|
acct.SetSplitSize(n)
|
||||||
|
if acct.splitDir == config.SPLIT_HORIZONTAL || n == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acct.splitDir = config.SPLIT_HORIZONTAL
|
||||||
|
acct.grid = ui.NewGrid().Rows([]ui.GridSpec{
|
||||||
|
// Add 1 so that the splitSize is the number of visible messages
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: func() int { return acct.SplitSize() + 1 }},
|
||||||
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
||||||
|
}).Columns([]ui.GridSpec{
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: func() int {
|
||||||
|
return acct.UiConfig().SidebarWidth
|
||||||
|
}},
|
||||||
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
||||||
|
})
|
||||||
|
|
||||||
|
acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.UiConfig())).Span(2, 1)
|
||||||
|
acct.grid.AddChild(ui.NewBordered(acct.msglist, ui.BORDER_BOTTOM, acct.UiConfig())).At(0, 1)
|
||||||
|
acct.split, _ = NewMessageViewer(acct, nil)
|
||||||
|
acct.grid.AddChild(acct.split).At(1, 1)
|
||||||
|
msg, err := acct.SelectedMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("split: load message error: %v", err)
|
||||||
|
}
|
||||||
|
acct.updateSplitView(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vsplit splits the message list view vertically. The message list will be n
|
||||||
|
// rows wide. If n is 0, any existing split is removed
|
||||||
|
func (acct *AccountView) Vsplit(n int) {
|
||||||
|
acct.SetSplitSize(n)
|
||||||
|
if acct.splitDir == config.SPLIT_VERTICAL || n == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acct.splitDir = config.SPLIT_VERTICAL
|
||||||
|
acct.grid = ui.NewGrid().Rows([]ui.GridSpec{
|
||||||
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
||||||
|
}).Columns([]ui.GridSpec{
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: func() int {
|
||||||
|
return acct.UiConfig().SidebarWidth
|
||||||
|
}},
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: acct.SplitSize},
|
||||||
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
||||||
|
})
|
||||||
|
|
||||||
|
acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.UiConfig())).At(0, 0)
|
||||||
|
acct.grid.AddChild(ui.NewBordered(acct.msglist, ui.BORDER_RIGHT, acct.UiConfig())).At(0, 1)
|
||||||
|
acct.split, _ = NewMessageViewer(acct, nil)
|
||||||
|
acct.grid.AddChild(acct.split).At(0, 2)
|
||||||
|
msg, err := acct.SelectedMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("split: load message error: %v", err)
|
||||||
|
}
|
||||||
|
acct.updateSplitView(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTitle executes the title template and sets the tab title
|
||||||
|
func (acct *AccountView) setTitle() {
|
||||||
|
if acct.tab == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := state.NewDataSetter()
|
||||||
|
data.SetAccount(acct.acct)
|
||||||
|
data.SetFolder(acct.Directories().SelectedDirectory())
|
||||||
|
data.SetRUE(acct.dirlist.List(), acct.dirlist.GetRUECount)
|
||||||
|
data.SetState(&acct.state)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := templates.Render(acct.UiConfig().TabTitleAccount, &buf, data.Data())
|
||||||
|
if err != nil {
|
||||||
|
acct.PushError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acct.tab.SetTitle(buf.String())
|
||||||
|
}
|
||||||
+962
@@ -0,0 +1,962 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/go-opt/v2"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp"
|
||||||
|
"github.com/emersion/go-message/mail"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/crypto"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Aerc struct {
|
||||||
|
accounts map[string]*AccountView
|
||||||
|
cmd func(string, *config.AccountConfig, *models.MessageInfo) error
|
||||||
|
cmdHistory lib.History
|
||||||
|
complete func(ctx context.Context, cmd string) ([]opt.Completion, string)
|
||||||
|
focused ui.Interactive
|
||||||
|
grid *ui.Grid
|
||||||
|
simulating int
|
||||||
|
statusbar *ui.Stack
|
||||||
|
statusline *StatusLine
|
||||||
|
pasting bool
|
||||||
|
pendingKeys []config.KeyStroke
|
||||||
|
prompts *ui.Stack
|
||||||
|
tabs *ui.Tabs
|
||||||
|
beep func()
|
||||||
|
dialog ui.DrawableInteractive
|
||||||
|
|
||||||
|
Crypto crypto.Provider
|
||||||
|
}
|
||||||
|
|
||||||
|
type Choice struct {
|
||||||
|
Key string
|
||||||
|
Text string
|
||||||
|
Command string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) Init(
|
||||||
|
crypto crypto.Provider,
|
||||||
|
cmd func(string, *config.AccountConfig, *models.MessageInfo) error,
|
||||||
|
complete func(ctx context.Context, cmd string) ([]opt.Completion, string), cmdHistory lib.History,
|
||||||
|
deferLoop chan struct{},
|
||||||
|
) {
|
||||||
|
tabs := ui.NewTabs(func(d ui.Drawable) *config.UIConfig {
|
||||||
|
acct := aerc.account(d)
|
||||||
|
if acct != nil {
|
||||||
|
return config.Ui.ForAccount(acct.Name())
|
||||||
|
}
|
||||||
|
return config.Ui
|
||||||
|
})
|
||||||
|
|
||||||
|
statusbar := ui.NewStack(config.Ui)
|
||||||
|
statusline := &StatusLine{}
|
||||||
|
statusbar.Push(statusline)
|
||||||
|
|
||||||
|
grid := ui.NewGrid().Rows([]ui.GridSpec{
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
|
||||||
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
|
||||||
|
}).Columns([]ui.GridSpec{
|
||||||
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
||||||
|
})
|
||||||
|
grid.AddChild(tabs.TabStrip)
|
||||||
|
grid.AddChild(tabs.TabContent).At(1, 0)
|
||||||
|
grid.AddChild(statusbar).At(2, 0)
|
||||||
|
|
||||||
|
aerc.accounts = make(map[string]*AccountView)
|
||||||
|
aerc.cmd = cmd
|
||||||
|
aerc.cmdHistory = cmdHistory
|
||||||
|
aerc.complete = complete
|
||||||
|
aerc.grid = grid
|
||||||
|
aerc.statusbar = statusbar
|
||||||
|
aerc.statusline = statusline
|
||||||
|
aerc.prompts = ui.NewStack(config.Ui)
|
||||||
|
aerc.tabs = tabs
|
||||||
|
aerc.Crypto = crypto
|
||||||
|
|
||||||
|
for _, acct := range config.Accounts {
|
||||||
|
view, err := NewAccountView(acct, deferLoop)
|
||||||
|
if err != nil {
|
||||||
|
tabs.Add(errorScreen(err.Error()), acct.Name, false)
|
||||||
|
} else {
|
||||||
|
aerc.accounts[acct.Name] = view
|
||||||
|
view.tab = tabs.Add(view, acct.Name, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.Accounts) == 0 {
|
||||||
|
wizard := NewAccountWizard()
|
||||||
|
wizard.Focus(true)
|
||||||
|
aerc.NewTab(wizard, "New account", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.Select(0)
|
||||||
|
|
||||||
|
tabs.CloseTab = func(index int) {
|
||||||
|
tab := aerc.tabs.Get(index)
|
||||||
|
if tab == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch content := tab.Content.(type) {
|
||||||
|
case *AccountView:
|
||||||
|
return
|
||||||
|
case *AccountWizard:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
aerc.RemoveTab(content, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aerc.showConfigWarnings()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) showConfigWarnings() {
|
||||||
|
var dialogs []ui.DrawableInteractive
|
||||||
|
|
||||||
|
callback := func(string, error) {
|
||||||
|
aerc.CloseDialog()
|
||||||
|
if len(dialogs) > 0 {
|
||||||
|
d := dialogs[0]
|
||||||
|
dialogs = dialogs[1:]
|
||||||
|
aerc.AddDialog(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, w := range config.Warnings {
|
||||||
|
dialogs = append(dialogs, NewSelectorDialog(
|
||||||
|
w.Title, w.Body, []string{"OK"}, 0,
|
||||||
|
aerc.SelectedAccountUiConfig(),
|
||||||
|
callback,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
callback("", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) OnBeep(f func()) {
|
||||||
|
aerc.beep = f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) Beep() {
|
||||||
|
if aerc.beep == nil {
|
||||||
|
log.Warnf("should beep, but no beeper")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aerc.beep()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) HandleMessage(msg types.WorkerMessage) {
|
||||||
|
if acct, ok := aerc.accounts[msg.Account()]; ok {
|
||||||
|
acct.onMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) Focus(focus bool) {
|
||||||
|
// who cares
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) Draw(ctx *ui.Context) {
|
||||||
|
if len(aerc.prompts.Children()) > 0 {
|
||||||
|
previous := aerc.focused
|
||||||
|
prompt := aerc.prompts.Pop().(*ExLine)
|
||||||
|
prompt.finish = func() {
|
||||||
|
aerc.statusbar.Pop()
|
||||||
|
aerc.focus(previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
aerc.statusbar.Push(prompt)
|
||||||
|
aerc.focus(prompt)
|
||||||
|
}
|
||||||
|
aerc.grid.Draw(ctx)
|
||||||
|
if aerc.dialog != nil {
|
||||||
|
w, h := ctx.Width(), ctx.Height()
|
||||||
|
if d, ok := aerc.dialog.(Dialog); ok {
|
||||||
|
xstart, width := d.ContextWidth()
|
||||||
|
ystart, height := d.ContextHeight()
|
||||||
|
aerc.dialog.Draw(
|
||||||
|
ctx.Subcontext(xstart(w), ystart(h),
|
||||||
|
width(w), height(h)))
|
||||||
|
} else if w > 8 && h > 4 {
|
||||||
|
aerc.dialog.Draw(ctx.Subcontext(4, h/2-2, w-8, 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) HumanReadableBindings() []string {
|
||||||
|
var result []string
|
||||||
|
binds := aerc.getBindings()
|
||||||
|
format := func(s string) string {
|
||||||
|
return strings.ReplaceAll(s, "%", "%%")
|
||||||
|
}
|
||||||
|
annotate := func(b *config.Binding) string {
|
||||||
|
if b.Annotation == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "[" + b.Annotation + "]"
|
||||||
|
}
|
||||||
|
fmtStr := "%10s %s %s"
|
||||||
|
for _, bind := range binds.Bindings {
|
||||||
|
result = append(result, fmt.Sprintf(fmtStr,
|
||||||
|
format(config.FormatKeyStrokes(bind.Input)),
|
||||||
|
format(config.FormatKeyStrokes(bind.Output)),
|
||||||
|
annotate(bind),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if binds.Globals && config.Binds.Global != nil {
|
||||||
|
for _, bind := range config.Binds.Global.Bindings {
|
||||||
|
result = append(result, fmt.Sprintf(fmtStr+" (Globals)",
|
||||||
|
format(config.FormatKeyStrokes(bind.Input)),
|
||||||
|
format(config.FormatKeyStrokes(bind.Output)),
|
||||||
|
annotate(bind),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, fmt.Sprintf(fmtStr,
|
||||||
|
"$ex",
|
||||||
|
fmt.Sprintf("'%c'", binds.ExKey.Key), "",
|
||||||
|
))
|
||||||
|
result = append(result, fmt.Sprintf(fmtStr,
|
||||||
|
"Globals",
|
||||||
|
fmt.Sprintf("%v", binds.Globals), "",
|
||||||
|
))
|
||||||
|
sort.Strings(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) getBindings() *config.KeyBindings {
|
||||||
|
selectedAccountName := ""
|
||||||
|
if aerc.SelectedAccount() != nil {
|
||||||
|
selectedAccountName = aerc.SelectedAccount().acct.Name
|
||||||
|
}
|
||||||
|
switch view := aerc.SelectedTabContent().(type) {
|
||||||
|
case *AccountView:
|
||||||
|
binds := config.Binds.MessageList.ForAccount(selectedAccountName)
|
||||||
|
return binds.ForFolder(view.SelectedDirectory())
|
||||||
|
case *AccountWizard:
|
||||||
|
return config.Binds.AccountWizard
|
||||||
|
case *Composer:
|
||||||
|
var binds *config.KeyBindings
|
||||||
|
switch view.Bindings() {
|
||||||
|
case "compose::editor":
|
||||||
|
binds = config.Binds.ComposeEditor.ForAccount(
|
||||||
|
selectedAccountName)
|
||||||
|
case "compose::review":
|
||||||
|
binds = config.Binds.ComposeReview.ForAccount(
|
||||||
|
selectedAccountName)
|
||||||
|
default:
|
||||||
|
binds = config.Binds.Compose.ForAccount(
|
||||||
|
selectedAccountName)
|
||||||
|
}
|
||||||
|
return binds.ForFolder(view.SelectedDirectory())
|
||||||
|
case *MessageViewer:
|
||||||
|
var binds *config.KeyBindings
|
||||||
|
switch view.Bindings() {
|
||||||
|
case "view::passthrough":
|
||||||
|
binds = config.Binds.MessageViewPassthrough.ForAccount(
|
||||||
|
selectedAccountName)
|
||||||
|
default:
|
||||||
|
binds = config.Binds.MessageView.ForAccount(
|
||||||
|
selectedAccountName)
|
||||||
|
}
|
||||||
|
return binds.ForFolder(view.SelectedAccount().SelectedDirectory())
|
||||||
|
case *Terminal:
|
||||||
|
return config.Binds.Terminal
|
||||||
|
default:
|
||||||
|
return config.Binds.Global
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) simulate(strokes []config.KeyStroke) {
|
||||||
|
aerc.pendingKeys = []config.KeyStroke{}
|
||||||
|
bindings := aerc.getBindings()
|
||||||
|
complete := aerc.SelectedAccountUiConfig().CompletionMinChars != config.MANUAL_COMPLETE
|
||||||
|
aerc.simulating += 1
|
||||||
|
|
||||||
|
for _, stroke := range strokes {
|
||||||
|
simulated := vaxis.Key{
|
||||||
|
Keycode: stroke.Key,
|
||||||
|
Modifiers: stroke.Modifiers,
|
||||||
|
}
|
||||||
|
if unicode.IsUpper(stroke.Key) {
|
||||||
|
simulated.Keycode = unicode.ToLower(stroke.Key)
|
||||||
|
simulated.Modifiers |= vaxis.ModShift
|
||||||
|
}
|
||||||
|
// If none of these mods are present, set the text field to
|
||||||
|
// enable matching keys like ":"
|
||||||
|
if stroke.Modifiers&vaxis.ModCtrl == 0 &&
|
||||||
|
stroke.Modifiers&vaxis.ModAlt == 0 &&
|
||||||
|
stroke.Modifiers&vaxis.ModSuper == 0 &&
|
||||||
|
stroke.Modifiers&vaxis.ModHyper == 0 {
|
||||||
|
|
||||||
|
simulated.Text = string(stroke.Key)
|
||||||
|
}
|
||||||
|
aerc.Event(simulated)
|
||||||
|
complete = stroke == bindings.CompleteKey
|
||||||
|
}
|
||||||
|
aerc.simulating -= 1
|
||||||
|
if exline, ok := aerc.focused.(*ExLine); ok {
|
||||||
|
// we are still focused on the exline, turn on tab complete
|
||||||
|
exline.TabComplete(func(ctx context.Context, cmd string) ([]opt.Completion, string) {
|
||||||
|
return aerc.complete(ctx, cmd)
|
||||||
|
})
|
||||||
|
if complete {
|
||||||
|
// force completion now
|
||||||
|
exline.Event(vaxis.Key{Keycode: vaxis.KeyTab})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) Event(event vaxis.Event) bool {
|
||||||
|
if config.General.QuakeMode {
|
||||||
|
if e, ok := event.(vaxis.Key); ok && e.MatchString("F1") {
|
||||||
|
ToggleQuake()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if aerc.dialog != nil {
|
||||||
|
return aerc.dialog.Event(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if aerc.focused != nil {
|
||||||
|
return aerc.focused.Event(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event := event.(type) {
|
||||||
|
// TODO: more vaxis events handling
|
||||||
|
case vaxis.Key:
|
||||||
|
// If we are in a bracketed paste, don't process the keys for
|
||||||
|
// bindings
|
||||||
|
if aerc.pasting {
|
||||||
|
interactive, ok := aerc.SelectedTabContent().(ui.Interactive)
|
||||||
|
if ok {
|
||||||
|
return interactive.Event(event)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
aerc.statusline.Expire()
|
||||||
|
stroke := config.KeyStroke{
|
||||||
|
Modifiers: event.Modifiers,
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case event.ShiftedCode != 0:
|
||||||
|
stroke.Key = event.ShiftedCode
|
||||||
|
stroke.Modifiers &^= vaxis.ModShift
|
||||||
|
default:
|
||||||
|
stroke.Key = event.Keycode
|
||||||
|
}
|
||||||
|
aerc.pendingKeys = append(aerc.pendingKeys, stroke)
|
||||||
|
ui.Invalidate()
|
||||||
|
bindings := aerc.getBindings()
|
||||||
|
incomplete := false
|
||||||
|
result, strokes := bindings.GetBinding(aerc.pendingKeys)
|
||||||
|
switch result {
|
||||||
|
case config.BINDING_FOUND:
|
||||||
|
aerc.simulate(strokes)
|
||||||
|
return true
|
||||||
|
case config.BINDING_INCOMPLETE:
|
||||||
|
incomplete = true
|
||||||
|
case config.BINDING_NOT_FOUND:
|
||||||
|
}
|
||||||
|
if bindings.Globals {
|
||||||
|
result, strokes = config.Binds.Global.GetBinding(aerc.pendingKeys)
|
||||||
|
switch result {
|
||||||
|
case config.BINDING_FOUND:
|
||||||
|
aerc.simulate(strokes)
|
||||||
|
return true
|
||||||
|
case config.BINDING_INCOMPLETE:
|
||||||
|
incomplete = true
|
||||||
|
case config.BINDING_NOT_FOUND:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !incomplete {
|
||||||
|
aerc.pendingKeys = []config.KeyStroke{}
|
||||||
|
exKey := bindings.ExKey
|
||||||
|
if aerc.simulating > 0 {
|
||||||
|
// Keybindings still use : even if you change the ex key
|
||||||
|
exKey = config.Binds.Global.ExKey
|
||||||
|
}
|
||||||
|
if aerc.isExKey(event, exKey) {
|
||||||
|
aerc.BeginExCommand("")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
interactive, ok := aerc.SelectedTabContent().(ui.Interactive)
|
||||||
|
if ok {
|
||||||
|
return interactive.Event(event)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case vaxis.Mouse:
|
||||||
|
aerc.grid.MouseEvent(event.Col, event.Row, event)
|
||||||
|
return true
|
||||||
|
case vaxis.PasteStartEvent:
|
||||||
|
aerc.pasting = true
|
||||||
|
interactive, ok := aerc.SelectedTabContent().(ui.Interactive)
|
||||||
|
if ok {
|
||||||
|
return interactive.Event(event)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case vaxis.PasteEndEvent:
|
||||||
|
aerc.pasting = false
|
||||||
|
interactive, ok := aerc.SelectedTabContent().(ui.Interactive)
|
||||||
|
if ok {
|
||||||
|
return interactive.Event(event)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) SelectedAccount() *AccountView {
|
||||||
|
return aerc.account(aerc.SelectedTabContent())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) Account(name string) (*AccountView, error) {
|
||||||
|
if acct, ok := aerc.accounts[name]; ok {
|
||||||
|
return acct, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("account <%s> not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) PrevAccount() (*AccountView, error) {
|
||||||
|
cur := aerc.SelectedAccount()
|
||||||
|
if cur == nil {
|
||||||
|
return nil, fmt.Errorf("no account selected, cannot get prev")
|
||||||
|
}
|
||||||
|
for i, conf := range config.Accounts {
|
||||||
|
if conf.Name == cur.Name() {
|
||||||
|
i -= 1
|
||||||
|
if i == -1 {
|
||||||
|
i = len(config.Accounts) - 1
|
||||||
|
}
|
||||||
|
conf = config.Accounts[i]
|
||||||
|
return aerc.Account(conf.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no prev account")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) NextAccount() (*AccountView, error) {
|
||||||
|
cur := aerc.SelectedAccount()
|
||||||
|
if cur == nil {
|
||||||
|
return nil, fmt.Errorf("no account selected, cannot get next")
|
||||||
|
}
|
||||||
|
for i, conf := range config.Accounts {
|
||||||
|
if conf.Name == cur.Name() {
|
||||||
|
i += 1
|
||||||
|
if i == len(config.Accounts) {
|
||||||
|
i = 0
|
||||||
|
}
|
||||||
|
conf = config.Accounts[i]
|
||||||
|
return aerc.Account(conf.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no next account")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) AccountNames() []string {
|
||||||
|
results := make([]string, 0)
|
||||||
|
for name := range aerc.accounts {
|
||||||
|
results = append(results, name)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) account(d ui.Drawable) *AccountView {
|
||||||
|
switch tab := d.(type) {
|
||||||
|
case *AccountView:
|
||||||
|
return tab
|
||||||
|
case *MessageViewer:
|
||||||
|
return tab.SelectedAccount()
|
||||||
|
case *Composer:
|
||||||
|
return tab.Account()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) SelectedAccountUiConfig() *config.UIConfig {
|
||||||
|
acct := aerc.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return config.Ui
|
||||||
|
}
|
||||||
|
return acct.UiConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) SelectedTabContent() ui.Drawable {
|
||||||
|
tab := aerc.tabs.Selected()
|
||||||
|
if tab == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tab.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) SelectedTab() *ui.Tab {
|
||||||
|
return aerc.tabs.Selected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) NewTab(clickable ui.Drawable, name string, background bool) *ui.Tab {
|
||||||
|
tab := aerc.tabs.Add(clickable, name, background)
|
||||||
|
aerc.UpdateStatus()
|
||||||
|
return tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) RemoveTab(tab ui.Drawable, closeContent bool) {
|
||||||
|
aerc.tabs.Remove(tab)
|
||||||
|
aerc.UpdateStatus()
|
||||||
|
if content, ok := tab.(ui.Closeable); ok && closeContent {
|
||||||
|
content.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) ReplaceTab(tabSrc ui.Drawable, tabTarget ui.Drawable, name string, closeSrc bool) {
|
||||||
|
aerc.tabs.Replace(tabSrc, tabTarget, name)
|
||||||
|
if content, ok := tabSrc.(ui.Closeable); ok && closeSrc {
|
||||||
|
content.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) MoveTab(i int, relative bool) {
|
||||||
|
aerc.tabs.MoveTab(i, relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) PinTab() {
|
||||||
|
aerc.tabs.PinTab()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) UnpinTab() {
|
||||||
|
aerc.tabs.UnpinTab()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) NextTab() {
|
||||||
|
aerc.tabs.NextTab()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) PrevTab() {
|
||||||
|
aerc.tabs.PrevTab()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) SelectTab(name string) bool {
|
||||||
|
ok := aerc.tabs.SelectName(name)
|
||||||
|
if ok {
|
||||||
|
aerc.UpdateStatus()
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) SelectTabIndex(index int) bool {
|
||||||
|
ok := aerc.tabs.Select(index)
|
||||||
|
if ok {
|
||||||
|
aerc.UpdateStatus()
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) SelectTabAtOffset(offset int) {
|
||||||
|
aerc.tabs.SelectOffset(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) TabNames() []string {
|
||||||
|
return aerc.tabs.Names()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) SelectPreviousTab() bool {
|
||||||
|
return aerc.tabs.SelectPrevious()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) UpdateStatus() {
|
||||||
|
if acct := aerc.SelectedAccount(); acct != nil {
|
||||||
|
aerc.statusline.Update(acct)
|
||||||
|
} else {
|
||||||
|
aerc.statusline.Clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) SetError(err string) {
|
||||||
|
aerc.statusline.SetError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) PushStatus(text string, expiry time.Duration) *StatusMessage {
|
||||||
|
return aerc.statusline.Push(text, expiry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) PushError(text string) *StatusMessage {
|
||||||
|
return aerc.statusline.PushError(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) PushWarning(text string) *StatusMessage {
|
||||||
|
return aerc.statusline.PushWarning(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) PushSuccess(text string) *StatusMessage {
|
||||||
|
return aerc.statusline.PushSuccess(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) focus(item ui.Interactive) {
|
||||||
|
if aerc.focused == item {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if aerc.focused != nil {
|
||||||
|
aerc.focused.Focus(false)
|
||||||
|
}
|
||||||
|
aerc.focused = item
|
||||||
|
interactive, ok := aerc.SelectedTabContent().(ui.Interactive)
|
||||||
|
if item != nil {
|
||||||
|
item.Focus(true)
|
||||||
|
if ok {
|
||||||
|
interactive.Focus(false)
|
||||||
|
}
|
||||||
|
} else if ok {
|
||||||
|
interactive.Focus(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) BeginExCommand(cmd string) {
|
||||||
|
previous := aerc.focused
|
||||||
|
var tabComplete func(context.Context, string) ([]opt.Completion, string)
|
||||||
|
if aerc.simulating != 0 {
|
||||||
|
// Don't try to draw completions for simulated events
|
||||||
|
tabComplete = nil
|
||||||
|
} else {
|
||||||
|
tabComplete = aerc.complete
|
||||||
|
}
|
||||||
|
exline := NewExLine(cmd, func(cmd string) {
|
||||||
|
err := aerc.cmd(cmd, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
aerc.PushError(err.Error())
|
||||||
|
}
|
||||||
|
// only add to history if this is an unsimulated command,
|
||||||
|
// ie one not executed from a keybinding
|
||||||
|
if aerc.simulating == 0 {
|
||||||
|
aerc.cmdHistory.Add(cmd)
|
||||||
|
}
|
||||||
|
}, func() {
|
||||||
|
aerc.statusbar.Pop()
|
||||||
|
aerc.focus(previous)
|
||||||
|
}, tabComplete, aerc.cmdHistory)
|
||||||
|
aerc.statusbar.Push(exline)
|
||||||
|
aerc.focus(exline)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) PushPrompt(prompt *ExLine) {
|
||||||
|
aerc.prompts.Push(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) RegisterPrompt(prompt string, cmd string) {
|
||||||
|
p := NewPrompt(prompt, func(text string) {
|
||||||
|
if text != "" {
|
||||||
|
cmd += " " + opt.QuoteArg(text)
|
||||||
|
}
|
||||||
|
err := aerc.cmd(cmd, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
aerc.PushError(err.Error())
|
||||||
|
}
|
||||||
|
}, func(ctx context.Context, cmd string) ([]opt.Completion, string) {
|
||||||
|
return nil, "" // TODO: completions
|
||||||
|
})
|
||||||
|
aerc.prompts.Push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) RegisterChoices(choices []Choice) {
|
||||||
|
cmds := make(map[string]string)
|
||||||
|
texts := []string{}
|
||||||
|
for _, c := range choices {
|
||||||
|
text := fmt.Sprintf("[%s] %s", c.Key, c.Text)
|
||||||
|
if strings.Contains(c.Text, c.Key) {
|
||||||
|
text = strings.Replace(c.Text, c.Key, "["+c.Key+"]", 1)
|
||||||
|
}
|
||||||
|
texts = append(texts, text)
|
||||||
|
cmds[c.Key] = c.Command
|
||||||
|
}
|
||||||
|
prompt := strings.Join(texts, ", ") + "? "
|
||||||
|
p := NewPrompt(prompt, func(text string) {
|
||||||
|
cmd, ok := cmds[text]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := aerc.cmd(cmd, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
aerc.PushError(err.Error())
|
||||||
|
}
|
||||||
|
}, func(ctx context.Context, cmd string) ([]opt.Completion, string) {
|
||||||
|
return nil, "" // TODO: completions
|
||||||
|
})
|
||||||
|
aerc.prompts.Push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) Command(args []string) error {
|
||||||
|
switch {
|
||||||
|
case len(args) == 0:
|
||||||
|
return nil // noop success, i.e. ping
|
||||||
|
case strings.HasPrefix(args[0], "mailto:"):
|
||||||
|
mailto, err := url.Parse(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return aerc.mailto(mailto)
|
||||||
|
case strings.HasPrefix(args[0], "mbox:"):
|
||||||
|
return aerc.mbox(args[0])
|
||||||
|
case strings.HasPrefix(args[0], ":"):
|
||||||
|
cmdline := args[0]
|
||||||
|
if len(args) > 1 {
|
||||||
|
cmdline = opt.QuoteArgs(args...).String()
|
||||||
|
}
|
||||||
|
defer ui.Invalidate()
|
||||||
|
return aerc.cmd(cmdline, nil, nil)
|
||||||
|
default:
|
||||||
|
return errors.New("command not understood")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) mailto(addr *url.URL) error {
|
||||||
|
var subject string
|
||||||
|
var body string
|
||||||
|
var acctName string
|
||||||
|
var attachments []string
|
||||||
|
h := &mail.Header{}
|
||||||
|
to, err := mail.ParseAddressList(addr.Opaque)
|
||||||
|
if err != nil && addr.Opaque != "" {
|
||||||
|
return fmt.Errorf("Could not parse to: %w", err)
|
||||||
|
}
|
||||||
|
h.SetAddressList("to", to)
|
||||||
|
template := config.Templates.NewMessage
|
||||||
|
for key, vals := range addr.Query() {
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "account":
|
||||||
|
acctName = strings.Join(vals, "")
|
||||||
|
case "bcc":
|
||||||
|
list, err := mail.ParseAddressList(strings.Join(vals, ","))
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
h.SetAddressList("Bcc", list)
|
||||||
|
case "body":
|
||||||
|
body = strings.Join(vals, "\n")
|
||||||
|
case "cc":
|
||||||
|
list, err := mail.ParseAddressList(strings.Join(vals, ","))
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
h.SetAddressList("Cc", list)
|
||||||
|
case "in-reply-to":
|
||||||
|
for i, msgID := range vals {
|
||||||
|
if len(msgID) > 1 && msgID[0] == '<' &&
|
||||||
|
msgID[len(msgID)-1] == '>' {
|
||||||
|
vals[i] = msgID[1 : len(msgID)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.SetMsgIDList("In-Reply-To", vals)
|
||||||
|
case "subject":
|
||||||
|
subject = strings.Join(vals, ",")
|
||||||
|
h.SetText("Subject", subject)
|
||||||
|
case "template":
|
||||||
|
template = strings.Join(vals, "")
|
||||||
|
log.Tracef("template set to %s", template)
|
||||||
|
case "attach":
|
||||||
|
for _, path := range vals {
|
||||||
|
// remove a potential file:// prefix.
|
||||||
|
attachments = append(attachments, strings.TrimPrefix(path, "file://"))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// any other header gets ignored on purpose to avoid control headers
|
||||||
|
// being injected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
acct := aerc.SelectedAccount()
|
||||||
|
if acctName != "" {
|
||||||
|
if a, ok := aerc.accounts[acctName]; ok && a != nil {
|
||||||
|
acct = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
defer ui.Invalidate()
|
||||||
|
|
||||||
|
composer, err := NewComposer(acct,
|
||||||
|
acct.AccountConfig(), acct.Worker(),
|
||||||
|
config.Compose.EditHeaders, template, h, nil,
|
||||||
|
strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
composer.FocusEditor("subject")
|
||||||
|
title := "New email"
|
||||||
|
if subject != "" {
|
||||||
|
title = subject
|
||||||
|
composer.FocusTerminal()
|
||||||
|
}
|
||||||
|
if to == nil {
|
||||||
|
composer.FocusEditor("to")
|
||||||
|
}
|
||||||
|
composer.Tab = aerc.NewTab(composer, title, false)
|
||||||
|
|
||||||
|
for _, file := range attachments {
|
||||||
|
composer.AddAttachment(file)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) mbox(source string) error {
|
||||||
|
acctConf := config.AccountConfig{}
|
||||||
|
if selectedAcct := aerc.SelectedAccount(); selectedAcct != nil {
|
||||||
|
acctConf = *selectedAcct.acct
|
||||||
|
info := fmt.Sprintf("Loading outgoing mbox mail settings from account [%s]", selectedAcct.Name())
|
||||||
|
aerc.PushStatus(info, 10*time.Second)
|
||||||
|
log.Debugf(info)
|
||||||
|
} else {
|
||||||
|
acctConf.From = &mail.Address{Address: "user@localhost"}
|
||||||
|
}
|
||||||
|
acctConf.Name = "mbox"
|
||||||
|
acctConf.Source = source
|
||||||
|
acctConf.Default = "INBOX"
|
||||||
|
acctConf.Archive = "Archive"
|
||||||
|
acctConf.Postpone = "Drafts"
|
||||||
|
acctConf.CopyTo = []string{"Sent"}
|
||||||
|
|
||||||
|
defer ui.Invalidate()
|
||||||
|
|
||||||
|
mboxView, err := NewAccountView(&acctConf, nil)
|
||||||
|
if err != nil {
|
||||||
|
aerc.NewTab(errorScreen(err.Error()), acctConf.Name, false)
|
||||||
|
} else {
|
||||||
|
aerc.accounts[acctConf.Name] = mboxView
|
||||||
|
aerc.NewTab(mboxView, acctConf.Name, false)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) CloseBackends() error {
|
||||||
|
var returnErr error
|
||||||
|
for _, acct := range aerc.accounts {
|
||||||
|
var raw interface{} = acct.worker.Backend
|
||||||
|
c, ok := raw.(io.Closer)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err := c.Close()
|
||||||
|
if err != nil {
|
||||||
|
returnErr = err
|
||||||
|
log.Errorf("Closing backend failed for %s: %v", acct.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return returnErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) AddDialog(d ui.DrawableInteractive) {
|
||||||
|
aerc.dialog = d
|
||||||
|
aerc.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) CloseDialog() {
|
||||||
|
aerc.dialog = nil
|
||||||
|
aerc.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) GetPassword(title string, prompt string) (chText chan string, chErr chan error) {
|
||||||
|
chText = make(chan string, 1)
|
||||||
|
chErr = make(chan error, 1)
|
||||||
|
getPasswd := NewGetPasswd(title, prompt, func(pw string, err error) {
|
||||||
|
defer func() {
|
||||||
|
close(chErr)
|
||||||
|
close(chText)
|
||||||
|
aerc.CloseDialog()
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
chErr <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chErr <- nil
|
||||||
|
chText <- pw
|
||||||
|
})
|
||||||
|
aerc.AddDialog(getPasswd)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) DecryptKeys(keys []openpgp.Key, symmetric bool) (b []byte, err error) {
|
||||||
|
for _, key := range keys {
|
||||||
|
ident := key.Entity.PrimaryIdentity()
|
||||||
|
chPass, chErr := aerc.GetPassword("Decrypt PGP private key",
|
||||||
|
fmt.Sprintf("Enter password for %s (%8X)\nPress <ESC> to cancel",
|
||||||
|
ident.Name, key.PublicKey.KeyId))
|
||||||
|
|
||||||
|
for err := range chErr {
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pass := <-chPass
|
||||||
|
err = key.PrivateKey.Decrypt([]byte(pass))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorScreen is a widget that draws an error in the middle of the context
|
||||||
|
func errorScreen(s string) ui.Drawable {
|
||||||
|
errstyle := config.Ui.GetStyle(config.STYLE_ERROR)
|
||||||
|
text := ui.NewText(s, errstyle).Strategy(ui.TEXT_CENTER)
|
||||||
|
grid := ui.NewGrid().Rows([]ui.GridSpec{
|
||||||
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
||||||
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
|
||||||
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
||||||
|
}).Columns([]ui.GridSpec{
|
||||||
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
||||||
|
})
|
||||||
|
grid.AddChild(ui.NewFill(' ', vaxis.Style{})).At(0, 0)
|
||||||
|
grid.AddChild(text).At(1, 0)
|
||||||
|
grid.AddChild(ui.NewFill(' ', vaxis.Style{})).At(2, 0)
|
||||||
|
return grid
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aerc *Aerc) isExKey(key vaxis.Key, exKey config.KeyStroke) bool {
|
||||||
|
return key.Matches(exKey.Key, exKey.Modifiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CmdFallbackSearch checks cmds for the first executable available in PATH. An error is
|
||||||
|
// returned if none are found
|
||||||
|
func CmdFallbackSearch(cmds []string, silent bool) (string, error) {
|
||||||
|
var tried []string
|
||||||
|
for _, cmd := range cmds {
|
||||||
|
if cmd == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
params := strings.Split(cmd, " ")
|
||||||
|
_, err := exec.LookPath(params[0])
|
||||||
|
if err != nil {
|
||||||
|
tried = append(tried, cmd)
|
||||||
|
if !silent {
|
||||||
|
warn := fmt.Sprintf("cmd '%s' not found in PATH, using fallback", cmd)
|
||||||
|
PushWarning(warn)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no command found in PATH: %s", tried)
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/crypto"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ipc"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
"git.sr.ht/~rjarry/go-opt/v2"
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var aerc Aerc
|
||||||
|
|
||||||
|
func Init(
|
||||||
|
crypto crypto.Provider,
|
||||||
|
cmd func(string, *config.AccountConfig, *models.MessageInfo) error,
|
||||||
|
complete func(ctx context.Context, cmd string) ([]opt.Completion, string), history lib.History,
|
||||||
|
deferLoop chan struct{},
|
||||||
|
) {
|
||||||
|
aerc.Init(crypto, cmd, complete, history, deferLoop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Drawable() ui.DrawableInteractive { return &aerc }
|
||||||
|
func IPCHandler() ipc.Handler { return &aerc }
|
||||||
|
func Command(args []string) error { return aerc.Command(args) }
|
||||||
|
func HandleMessage(msg types.WorkerMessage) { aerc.HandleMessage(msg) }
|
||||||
|
|
||||||
|
func CloseBackends() error { return aerc.CloseBackends() }
|
||||||
|
|
||||||
|
func AddDialog(d ui.DrawableInteractive) { aerc.AddDialog(d) }
|
||||||
|
func CloseDialog() { aerc.CloseDialog() }
|
||||||
|
|
||||||
|
func HumanReadableBindings() []string {
|
||||||
|
return aerc.HumanReadableBindings()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Account(name string) (*AccountView, error) { return aerc.Account(name) }
|
||||||
|
func AccountNames() []string { return aerc.AccountNames() }
|
||||||
|
func NextAccount() (*AccountView, error) { return aerc.NextAccount() }
|
||||||
|
func PrevAccount() (*AccountView, error) { return aerc.PrevAccount() }
|
||||||
|
func SelectedAccount() *AccountView { return aerc.SelectedAccount() }
|
||||||
|
func SelectedAccountUiConfig() *config.UIConfig { return aerc.SelectedAccountUiConfig() }
|
||||||
|
|
||||||
|
func NextTab() { aerc.NextTab() }
|
||||||
|
func PrevTab() { aerc.PrevTab() }
|
||||||
|
func PinTab() { aerc.PinTab() }
|
||||||
|
func UnpinTab() { aerc.UnpinTab() }
|
||||||
|
func MoveTab(i int, relative bool) { aerc.MoveTab(i, relative) }
|
||||||
|
func TabNames() []string { return aerc.TabNames() }
|
||||||
|
func GetTab(i int) *ui.Tab { return aerc.tabs.Get(i) }
|
||||||
|
func SelectTab(name string) bool { return aerc.SelectTab(name) }
|
||||||
|
func SelectPreviousTab() bool { return aerc.SelectPreviousTab() }
|
||||||
|
func SelectedTab() *ui.Tab { return aerc.SelectedTab() }
|
||||||
|
func SelectedTabContent() ui.Drawable { return aerc.SelectedTabContent() }
|
||||||
|
func SelectTabIndex(index int) bool { return aerc.SelectTabIndex(index) }
|
||||||
|
func SelectTabAtOffset(offset int) { aerc.SelectTabAtOffset(offset) }
|
||||||
|
func RemoveTab(tab ui.Drawable, closeContent bool) { aerc.RemoveTab(tab, closeContent) }
|
||||||
|
func NewTab(clickable ui.Drawable, name string) *ui.Tab {
|
||||||
|
return aerc.NewTab(clickable, name, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBackgroundTab(clickable ui.Drawable, name string) *ui.Tab {
|
||||||
|
return aerc.NewTab(clickable, name, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReplaceTab(tabSrc ui.Drawable, tabTarget ui.Drawable, name string, closeSrc bool) {
|
||||||
|
aerc.ReplaceTab(tabSrc, tabTarget, name, closeSrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateStatus() { aerc.UpdateStatus() }
|
||||||
|
func PushPrompt(prompt *ExLine) { aerc.PushPrompt(prompt) }
|
||||||
|
func SetError(text string) { aerc.SetError(text) }
|
||||||
|
func PushError(text string) *StatusMessage { return aerc.PushError(text) }
|
||||||
|
func PushWarning(text string) *StatusMessage { return aerc.PushWarning(text) }
|
||||||
|
func PushSuccess(text string) *StatusMessage { return aerc.PushSuccess(text) }
|
||||||
|
func PushStatus(text string, expiry time.Duration) *StatusMessage {
|
||||||
|
return aerc.PushStatus(text, expiry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterChoices(choices []Choice) { aerc.RegisterChoices(choices) }
|
||||||
|
func RegisterPrompt(prompt string, cmd string) { aerc.RegisterPrompt(prompt, cmd) }
|
||||||
|
|
||||||
|
func CryptoProvider() crypto.Provider { return aerc.Crypto }
|
||||||
|
func DecryptKeys(keys []openpgp.Key, symmetric bool) (b []byte, err error) {
|
||||||
|
return aerc.DecryptKeys(keys, symmetric)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/auth"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthInfo struct {
|
||||||
|
authdetails *auth.Details
|
||||||
|
showInfo bool
|
||||||
|
uiConfig *config.UIConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthInfo(auth *auth.Details, showInfo bool, uiConfig *config.UIConfig) *AuthInfo {
|
||||||
|
return &AuthInfo{authdetails: auth, showInfo: showInfo, uiConfig: uiConfig}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthInfo) Draw(ctx *ui.Context) {
|
||||||
|
defaultStyle := a.uiConfig.GetStyle(config.STYLE_DEFAULT)
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle)
|
||||||
|
var text string
|
||||||
|
switch {
|
||||||
|
case a.authdetails == nil:
|
||||||
|
text = "(no header)"
|
||||||
|
ctx.Printf(0, 0, defaultStyle, "%s", text)
|
||||||
|
case a.authdetails.Err != nil:
|
||||||
|
style := a.uiConfig.GetStyle(config.STYLE_ERROR)
|
||||||
|
text = a.authdetails.Err.Error()
|
||||||
|
ctx.Printf(0, 0, style, "%s", text)
|
||||||
|
default:
|
||||||
|
checkBounds := func(x int) bool {
|
||||||
|
return x < ctx.Width()
|
||||||
|
}
|
||||||
|
setResult := func(result auth.Result) (string, vaxis.Style) {
|
||||||
|
switch result {
|
||||||
|
case auth.ResultNone:
|
||||||
|
return "none", defaultStyle
|
||||||
|
case auth.ResultNeutral:
|
||||||
|
return "neutral", a.uiConfig.GetStyle(config.STYLE_WARNING)
|
||||||
|
case auth.ResultPolicy:
|
||||||
|
return "policy", a.uiConfig.GetStyle(config.STYLE_WARNING)
|
||||||
|
case auth.ResultPass:
|
||||||
|
return "✓", a.uiConfig.GetStyle(config.STYLE_SUCCESS)
|
||||||
|
case auth.ResultFail:
|
||||||
|
return "✗", a.uiConfig.GetStyle(config.STYLE_ERROR)
|
||||||
|
default:
|
||||||
|
return string(result), a.uiConfig.GetStyle(config.STYLE_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x := 1
|
||||||
|
for i := 0; i < len(a.authdetails.Results); i++ {
|
||||||
|
if checkBounds(x) {
|
||||||
|
text, style := setResult(a.authdetails.Results[i])
|
||||||
|
if i > 0 {
|
||||||
|
text = " " + text
|
||||||
|
}
|
||||||
|
x += ctx.Printf(x, 0, style, "%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.showInfo {
|
||||||
|
infoText := ""
|
||||||
|
for i := 0; i < len(a.authdetails.Infos); i++ {
|
||||||
|
if i > 0 {
|
||||||
|
infoText += ","
|
||||||
|
}
|
||||||
|
infoText += a.authdetails.Infos[i]
|
||||||
|
if reason := a.authdetails.Reasons[i]; reason != "" {
|
||||||
|
infoText += reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if checkBounds(x) && infoText != "" {
|
||||||
|
if trunc := ctx.Width() - x - 3; trunc > 0 {
|
||||||
|
text = runewidth.Truncate(infoText, trunc, "…")
|
||||||
|
ctx.Printf(x, 0, defaultStyle, " (%s)", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthInfo) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
+1995
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Dialog interface {
|
||||||
|
ui.DrawableInteractive
|
||||||
|
ContextWidth() (func(int) int, func(int) int)
|
||||||
|
ContextHeight() (func(int) int, func(int) int)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dialog struct {
|
||||||
|
ui.DrawableInteractive
|
||||||
|
x func(int) int
|
||||||
|
y func(int) int
|
||||||
|
w func(int) int
|
||||||
|
h func(int) int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dialog) ContextWidth() (func(int) int, func(int) int) {
|
||||||
|
return d.x, d.w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dialog) ContextHeight() (func(int) int, func(int) int) {
|
||||||
|
return d.y, d.h
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDialog(
|
||||||
|
d ui.DrawableInteractive,
|
||||||
|
x func(int) int, y func(int) int,
|
||||||
|
w func(int) int, h func(int) int,
|
||||||
|
) *dialog {
|
||||||
|
return &dialog{DrawableInteractive: d, x: x, y: y, w: w, h: h}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultDialog creates a dialog window spanning half of the screen
|
||||||
|
func DefaultDialog(d ui.DrawableInteractive) Dialog {
|
||||||
|
position := SelectedAccountUiConfig().DialogPosition
|
||||||
|
width := SelectedAccountUiConfig().DialogWidth
|
||||||
|
height := SelectedAccountUiConfig().DialogHeight
|
||||||
|
return NewDialog(d,
|
||||||
|
// horizontal starting position in columns from the left
|
||||||
|
func(w int) int {
|
||||||
|
return (w * (100 - width)) / 200
|
||||||
|
},
|
||||||
|
// vertical starting position in lines from the top
|
||||||
|
func(h int) int {
|
||||||
|
switch position {
|
||||||
|
case "center":
|
||||||
|
return (h * (100 - height)) / 200
|
||||||
|
case "bottom":
|
||||||
|
return h - (h * height / 100)
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// dialog width from the starting column
|
||||||
|
func(w int) int {
|
||||||
|
return w * width / 100
|
||||||
|
},
|
||||||
|
// dialog height from the starting line
|
||||||
|
func(h int) int {
|
||||||
|
if position == "bottom" {
|
||||||
|
return h*height/100 - 1
|
||||||
|
}
|
||||||
|
return h * height / 100
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+559
@@ -0,0 +1,559 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/templates"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DirectoryLister interface {
|
||||||
|
ui.Drawable
|
||||||
|
|
||||||
|
Selected() string
|
||||||
|
Previous() string
|
||||||
|
|
||||||
|
Select(string)
|
||||||
|
Open(string, string, time.Duration, func(types.WorkerMessage), bool)
|
||||||
|
|
||||||
|
Update(types.WorkerMessage)
|
||||||
|
List() []string
|
||||||
|
ClearList()
|
||||||
|
|
||||||
|
OnVirtualNode(func())
|
||||||
|
|
||||||
|
NextPrev(int)
|
||||||
|
|
||||||
|
CollapseFolder(string)
|
||||||
|
ExpandFolder(string)
|
||||||
|
|
||||||
|
SelectedMsgStore() (*lib.MessageStore, bool)
|
||||||
|
MsgStore(string) (*lib.MessageStore, bool)
|
||||||
|
SelectedDirectory() *models.Directory
|
||||||
|
Directory(string) *models.Directory
|
||||||
|
SetMsgStore(*models.Directory, *lib.MessageStore)
|
||||||
|
|
||||||
|
FilterDirs([]string, []string, bool) []string
|
||||||
|
GetRUECount(string) (int, int, int)
|
||||||
|
|
||||||
|
UiConfig(string) *config.UIConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type DirectoryList struct {
|
||||||
|
Scrollable
|
||||||
|
acctConf *config.AccountConfig
|
||||||
|
store *lib.DirStore
|
||||||
|
dirs []string
|
||||||
|
selecting string
|
||||||
|
selected string
|
||||||
|
previous string
|
||||||
|
spinner *Spinner
|
||||||
|
worker *types.Worker
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDirectoryList(acctConf *config.AccountConfig,
|
||||||
|
worker *types.Worker,
|
||||||
|
) DirectoryLister {
|
||||||
|
dirlist := &DirectoryList{
|
||||||
|
acctConf: acctConf,
|
||||||
|
store: lib.NewDirStore(),
|
||||||
|
worker: worker,
|
||||||
|
}
|
||||||
|
dirlist.NewContext()
|
||||||
|
uiConf := dirlist.UiConfig("")
|
||||||
|
dirlist.spinner = NewSpinner(uiConf)
|
||||||
|
dirlist.spinner.Start()
|
||||||
|
|
||||||
|
if uiConf.DirListTree {
|
||||||
|
return NewDirectoryTree(dirlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) NewContext() {
|
||||||
|
if dirlist.cancel != nil {
|
||||||
|
dirlist.cancel()
|
||||||
|
}
|
||||||
|
dirlist.ctx, dirlist.cancel = context.WithCancel(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) UiConfig(dir string) *config.UIConfig {
|
||||||
|
if dir == "" {
|
||||||
|
dir = dirlist.Selected()
|
||||||
|
}
|
||||||
|
return config.Ui.ForAccount(dirlist.acctConf.Name).ForFolder(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) List() []string {
|
||||||
|
return dirlist.dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) ClearList() {
|
||||||
|
dirlist.dirs = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) OnVirtualNode(_ func()) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) Update(msg types.WorkerMessage) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
switch msg := msg.InResponseTo().(type) {
|
||||||
|
case *types.OpenDirectory:
|
||||||
|
dirlist.previous = dirlist.selected
|
||||||
|
dirlist.selected = msg.Directory
|
||||||
|
dirlist.filterDirsByFoldersConfig()
|
||||||
|
hasSelected := false
|
||||||
|
for _, d := range dirlist.dirs {
|
||||||
|
if d == dirlist.selected {
|
||||||
|
hasSelected = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasSelected && dirlist.selected != "" {
|
||||||
|
dirlist.dirs = append(dirlist.dirs, dirlist.selected)
|
||||||
|
}
|
||||||
|
if dirlist.acctConf.EnableFoldersSort {
|
||||||
|
sort.Strings(dirlist.dirs)
|
||||||
|
}
|
||||||
|
dirlist.sortDirsByFoldersSortConfig()
|
||||||
|
store, ok := dirlist.SelectedMsgStore()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.SetContext(msg.Context)
|
||||||
|
case *types.ListDirectories:
|
||||||
|
dirlist.filterDirsByFoldersConfig()
|
||||||
|
dirlist.sortDirsByFoldersSortConfig()
|
||||||
|
dirlist.spinner.Stop()
|
||||||
|
dirlist.Invalidate()
|
||||||
|
case *types.RemoveDirectory:
|
||||||
|
dirlist.store.Remove(msg.Directory)
|
||||||
|
dirlist.filterDirsByFoldersConfig()
|
||||||
|
dirlist.sortDirsByFoldersSortConfig()
|
||||||
|
case *types.CreateDirectory:
|
||||||
|
dirlist.filterDirsByFoldersConfig()
|
||||||
|
dirlist.sortDirsByFoldersSortConfig()
|
||||||
|
dirlist.Invalidate()
|
||||||
|
}
|
||||||
|
case *types.DirectoryInfo:
|
||||||
|
dir := dirlist.Directory(msg.Info.Name)
|
||||||
|
if dir == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir.Exists = msg.Info.Exists
|
||||||
|
dir.Recent = msg.Info.Recent
|
||||||
|
dir.Unseen = msg.Info.Unseen
|
||||||
|
if msg.Refetch {
|
||||||
|
store, ok := dirlist.SelectedMsgStore()
|
||||||
|
if ok {
|
||||||
|
store.Sort(store.GetCurrentSortCriteria(), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) CollapseFolder(string) {
|
||||||
|
// no effect for the DirectoryList
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) ExpandFolder(string) {
|
||||||
|
// no effect for the DirectoryList
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) Select(name string) {
|
||||||
|
dirlist.Open(name, "", dirlist.UiConfig(name).DirListDelay, nil, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) Open(name string, query string, delay time.Duration,
|
||||||
|
cb func(types.WorkerMessage), force bool,
|
||||||
|
) {
|
||||||
|
dirlist.selecting = name
|
||||||
|
|
||||||
|
dirlist.NewContext()
|
||||||
|
|
||||||
|
go func(ctx context.Context) {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(delay):
|
||||||
|
dirlist.worker.PostAction(&types.OpenDirectory{
|
||||||
|
Context: ctx,
|
||||||
|
Directory: name,
|
||||||
|
Query: query,
|
||||||
|
Force: force,
|
||||||
|
},
|
||||||
|
func(msg types.WorkerMessage) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Error:
|
||||||
|
dirlist.selecting = ""
|
||||||
|
log.Errorf("(%s) couldn't open directory %s: %v",
|
||||||
|
dirlist.acctConf.Name,
|
||||||
|
name,
|
||||||
|
msg.Error)
|
||||||
|
case *types.Cancelled:
|
||||||
|
log.Debugf("OpenDirectory cancelled")
|
||||||
|
}
|
||||||
|
if cb != nil {
|
||||||
|
cb(msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Tracef("dirlist: skip %s", name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}(dirlist.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) Selected() string {
|
||||||
|
return dirlist.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) Previous() string {
|
||||||
|
return dirlist.previous
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the Recent, Unread, and Exist counts for the named directory
|
||||||
|
func (dirlist *DirectoryList) GetRUECount(name string) (int, int, int) {
|
||||||
|
dir := dirlist.Directory(name)
|
||||||
|
if dir == nil {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
return dir.Recent, dir.Unseen, dir.Exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) Draw(ctx *ui.Context) {
|
||||||
|
uiConfig := dirlist.UiConfig("")
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
|
||||||
|
uiConfig.GetStyle(config.STYLE_DIRLIST_DEFAULT))
|
||||||
|
|
||||||
|
if dirlist.spinner.IsRunning() {
|
||||||
|
dirlist.spinner.Draw(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(dirlist.dirs) == 0 {
|
||||||
|
style := uiConfig.GetStyle(config.STYLE_DIRLIST_DEFAULT)
|
||||||
|
ctx.Printf(0, 0, style, "%s", uiConfig.EmptyDirlist)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dirlist.UpdateScroller(ctx.Height(), len(dirlist.dirs))
|
||||||
|
dirlist.EnsureScroll(findString(dirlist.dirs, dirlist.selecting))
|
||||||
|
|
||||||
|
textWidth := ctx.Width()
|
||||||
|
if dirlist.NeedScrollbar() {
|
||||||
|
textWidth -= 1
|
||||||
|
}
|
||||||
|
if textWidth < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listCtx := ctx.Subcontext(0, 0, textWidth, ctx.Height())
|
||||||
|
|
||||||
|
data := state.NewDataSetter()
|
||||||
|
data.SetAccount(dirlist.acctConf)
|
||||||
|
|
||||||
|
for i, name := range dirlist.dirs {
|
||||||
|
if i < dirlist.Scroll() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := i - dirlist.Scroll()
|
||||||
|
if row >= ctx.Height() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
data.SetFolder(dirlist.Directory(name))
|
||||||
|
data.SetRUE([]string{name}, dirlist.GetRUECount)
|
||||||
|
left, right, style := dirlist.renderDir(
|
||||||
|
name, uiConfig, data.Data(),
|
||||||
|
name == dirlist.selecting, listCtx.Width(),
|
||||||
|
)
|
||||||
|
listCtx.Printf(0, row, style, "%s %s", left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dirlist.NeedScrollbar() {
|
||||||
|
scrollBarCtx := ctx.Subcontext(ctx.Width()-1, 0, 1, ctx.Height())
|
||||||
|
dirlist.drawScrollbar(scrollBarCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) renderDir(
|
||||||
|
path string, conf *config.UIConfig, data models.TemplateData,
|
||||||
|
selected bool, width int,
|
||||||
|
) (string, string, vaxis.Style) {
|
||||||
|
var left, right string
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
var styles []config.StyleObject
|
||||||
|
var style vaxis.Style
|
||||||
|
|
||||||
|
r, u, _ := dirlist.GetRUECount(path)
|
||||||
|
if u > 0 {
|
||||||
|
styles = append(styles, config.STYLE_DIRLIST_UNREAD)
|
||||||
|
}
|
||||||
|
if r > 0 {
|
||||||
|
styles = append(styles, config.STYLE_DIRLIST_RECENT)
|
||||||
|
}
|
||||||
|
conf = conf.ForFolder(path)
|
||||||
|
if selected {
|
||||||
|
style = conf.GetComposedStyleSelected(
|
||||||
|
config.STYLE_DIRLIST_DEFAULT, styles)
|
||||||
|
} else {
|
||||||
|
style = conf.GetComposedStyle(
|
||||||
|
config.STYLE_DIRLIST_DEFAULT, styles)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := templates.Render(conf.DirListLeft, &buf, data)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("dirlist-left: %s", err)
|
||||||
|
left = err.Error()
|
||||||
|
style = conf.GetStyle(config.STYLE_ERROR)
|
||||||
|
} else {
|
||||||
|
left = buf.String()
|
||||||
|
}
|
||||||
|
buf.Reset()
|
||||||
|
err = templates.Render(conf.DirListRight, &buf, data)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("dirlist-right: %s", err)
|
||||||
|
right = err.Error()
|
||||||
|
style = conf.GetStyle(config.STYLE_ERROR)
|
||||||
|
} else {
|
||||||
|
right = buf.String()
|
||||||
|
}
|
||||||
|
buf.Reset()
|
||||||
|
|
||||||
|
lbuf := ui.StyledString(left)
|
||||||
|
ui.ApplyAttrs(lbuf, style)
|
||||||
|
lwidth := lbuf.Len()
|
||||||
|
rbuf := ui.StyledString(right)
|
||||||
|
ui.ApplyAttrs(rbuf, style)
|
||||||
|
rwidth := rbuf.Len()
|
||||||
|
|
||||||
|
if lwidth+rwidth+1 > width {
|
||||||
|
if rwidth > 3*width/4 {
|
||||||
|
rwidth = 3 * width / 4
|
||||||
|
}
|
||||||
|
lwidth = width - rwidth - 1
|
||||||
|
ui.TruncateHead(rbuf, rwidth)
|
||||||
|
right = rbuf.Encode()
|
||||||
|
ui.Truncate(lbuf, lwidth)
|
||||||
|
left = lbuf.Encode()
|
||||||
|
} else {
|
||||||
|
for i := 0; i < (width - lwidth - rwidth - 1); i += 1 {
|
||||||
|
lbuf.Cells = append(lbuf.Cells, vaxis.Cell{
|
||||||
|
Character: vaxis.Character{
|
||||||
|
Grapheme: " ",
|
||||||
|
Width: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
left = lbuf.Encode()
|
||||||
|
right = rbuf.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return left, right, style
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) drawScrollbar(ctx *ui.Context) {
|
||||||
|
gutterStyle := vaxis.Style{}
|
||||||
|
pillStyle := vaxis.Style{Attribute: vaxis.AttrReverse}
|
||||||
|
|
||||||
|
// gutter
|
||||||
|
ctx.Fill(0, 0, 1, ctx.Height(), ' ', gutterStyle)
|
||||||
|
|
||||||
|
// pill
|
||||||
|
pillSize := int(math.Ceil(float64(ctx.Height()) * dirlist.PercentVisible()))
|
||||||
|
pillOffset := int(math.Floor(float64(ctx.Height()) * dirlist.PercentScrolled()))
|
||||||
|
ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) MouseEvent(localX int, localY int, event vaxis.Event) {
|
||||||
|
if event, ok := event.(vaxis.Mouse); ok {
|
||||||
|
switch event.Button {
|
||||||
|
case vaxis.MouseLeftButton:
|
||||||
|
clickedDir, ok := dirlist.Clicked(localX, localY)
|
||||||
|
if ok {
|
||||||
|
dirlist.Select(clickedDir)
|
||||||
|
}
|
||||||
|
case vaxis.MouseWheelDown:
|
||||||
|
dirlist.Next()
|
||||||
|
case vaxis.MouseWheelUp:
|
||||||
|
dirlist.Prev()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) Clicked(x int, y int) (string, bool) {
|
||||||
|
if len(dirlist.dirs) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for i, name := range dirlist.dirs {
|
||||||
|
if i == y {
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) NextPrev(delta int) {
|
||||||
|
curIdx := findString(dirlist.dirs, dirlist.selecting)
|
||||||
|
if curIdx == len(dirlist.dirs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newIdx := curIdx + delta
|
||||||
|
ndirs := len(dirlist.dirs)
|
||||||
|
|
||||||
|
if ndirs == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if newIdx < 0 {
|
||||||
|
newIdx = ndirs - 1
|
||||||
|
} else if newIdx >= ndirs {
|
||||||
|
newIdx = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
dirlist.Select(dirlist.dirs[newIdx])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) Next() {
|
||||||
|
dirlist.NextPrev(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) Prev() {
|
||||||
|
dirlist.NextPrev(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderMatches(folder string, pattern string) bool {
|
||||||
|
if len(pattern) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if pattern[0] == '~' {
|
||||||
|
r, err := regexp.Compile(pattern[1:])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return r.Match([]byte(folder))
|
||||||
|
}
|
||||||
|
return pattern == folder
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortDirsByFoldersSortConfig sets dirlist.dirs to be sorted based on the
|
||||||
|
// AccountConfig.FoldersSort option. Folders not included in the option
|
||||||
|
// will be appended at the end in alphabetical order
|
||||||
|
func (dirlist *DirectoryList) sortDirsByFoldersSortConfig() {
|
||||||
|
if !dirlist.acctConf.EnableFoldersSort {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(dirlist.dirs, func(i, j int) bool {
|
||||||
|
foldersSort := dirlist.acctConf.FoldersSort
|
||||||
|
iInFoldersSort := findString(foldersSort, dirlist.dirs[i])
|
||||||
|
jInFoldersSort := findString(foldersSort, dirlist.dirs[j])
|
||||||
|
if iInFoldersSort >= 0 && jInFoldersSort >= 0 {
|
||||||
|
return iInFoldersSort < jInFoldersSort
|
||||||
|
}
|
||||||
|
if iInFoldersSort >= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if jInFoldersSort >= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return dirlist.dirs[i] < dirlist.dirs[j]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterDirsByFoldersConfig sets dirlist.dirs to the filtered subset of the
|
||||||
|
// dirstore, based on AccountConfig.Folders (inclusion) and
|
||||||
|
// AccountConfig.FoldersExclude (exclusion), in that order.
|
||||||
|
func (dirlist *DirectoryList) filterDirsByFoldersConfig() {
|
||||||
|
dirlist.dirs = dirlist.store.List()
|
||||||
|
|
||||||
|
// 'folders' (if available) is used to make the initial list and
|
||||||
|
// 'folders-exclude' removes from that list.
|
||||||
|
configFolders := dirlist.acctConf.Folders
|
||||||
|
dirlist.dirs = dirlist.FilterDirs(dirlist.dirs, configFolders, false)
|
||||||
|
|
||||||
|
configFoldersExclude := dirlist.acctConf.FoldersExclude
|
||||||
|
dirlist.dirs = dirlist.FilterDirs(dirlist.dirs, configFoldersExclude, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterDirs filters directories by the supplied filter. If exclude is false,
|
||||||
|
// the filter will only include directories from orig which exist in filters.
|
||||||
|
// If exclude is true, the directories in filters are removed from orig
|
||||||
|
func (dirlist *DirectoryList) FilterDirs(orig, filters []string, exclude bool) []string {
|
||||||
|
if len(filters) == 0 {
|
||||||
|
return orig
|
||||||
|
}
|
||||||
|
var dest []string
|
||||||
|
for _, folder := range orig {
|
||||||
|
// When excluding, include things by default, and vice-versa
|
||||||
|
include := exclude
|
||||||
|
for _, f := range filters {
|
||||||
|
if folderMatches(folder, f) {
|
||||||
|
// If matched an exclusion, don't include
|
||||||
|
// If matched an inclusion, do include
|
||||||
|
include = !exclude
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if include {
|
||||||
|
dest = append(dest, folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) SelectedMsgStore() (*lib.MessageStore, bool) {
|
||||||
|
return dirlist.store.MessageStore(dirlist.selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) MsgStore(name string) (*lib.MessageStore, bool) {
|
||||||
|
return dirlist.store.MessageStore(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) SelectedDirectory() *models.Directory {
|
||||||
|
return dirlist.store.Directory(dirlist.selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) Directory(name string) *models.Directory {
|
||||||
|
return dirlist.store.Directory(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dirlist *DirectoryList) SetMsgStore(dir *models.Directory, msgStore *lib.MessageStore) {
|
||||||
|
dirlist.store.SetMessageStore(dir, msgStore)
|
||||||
|
msgStore.OnUpdateDirs(func() {
|
||||||
|
dirlist.Invalidate()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func findString(slice []string, str string) int {
|
||||||
|
for i, s := range slice {
|
||||||
|
if str == s {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
+543
@@ -0,0 +1,543 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DirectoryTree struct {
|
||||||
|
*DirectoryList
|
||||||
|
|
||||||
|
listIdx int
|
||||||
|
list []*types.Thread
|
||||||
|
|
||||||
|
virtual bool
|
||||||
|
virtualCb func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDirectoryTree(dirlist *DirectoryList) DirectoryLister {
|
||||||
|
dt := &DirectoryTree{
|
||||||
|
DirectoryList: dirlist,
|
||||||
|
listIdx: -1,
|
||||||
|
virtualCb: func() {},
|
||||||
|
}
|
||||||
|
return dt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) OnVirtualNode(cb func()) {
|
||||||
|
dt.virtualCb = cb
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) Selected() string {
|
||||||
|
if dt.listIdx < 0 || dt.listIdx >= len(dt.list) {
|
||||||
|
return dt.DirectoryList.Selected()
|
||||||
|
}
|
||||||
|
node := dt.list[dt.listIdx]
|
||||||
|
elems := dt.nodeElems(node)
|
||||||
|
n := countLevels(node)
|
||||||
|
if n < 0 || n >= len(elems) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(elems[:(n+1)], dt.DirectoryList.worker.PathSeparator())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) SelectedDirectory() *models.Directory {
|
||||||
|
if dt.virtual {
|
||||||
|
return &models.Directory{
|
||||||
|
Name: dt.Selected(),
|
||||||
|
Role: models.VirtualRole,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dt.DirectoryList.SelectedDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) ClearList() {
|
||||||
|
dt.list = make([]*types.Thread, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) Update(msg types.WorkerMessage) {
|
||||||
|
selected := dt.Selected()
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
switch msg.InResponseTo().(type) {
|
||||||
|
case *types.RemoveDirectory, *types.ListDirectories, *types.CreateDirectory:
|
||||||
|
dt.DirectoryList.Update(msg)
|
||||||
|
dt.buildTree()
|
||||||
|
if selected != "" {
|
||||||
|
dt.reindex(selected)
|
||||||
|
}
|
||||||
|
dt.Invalidate()
|
||||||
|
default:
|
||||||
|
dt.DirectoryList.Update(msg)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
dt.DirectoryList.Update(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) Draw(ctx *ui.Context) {
|
||||||
|
uiConfig := dt.UiConfig("")
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
|
||||||
|
uiConfig.GetStyle(config.STYLE_DIRLIST_DEFAULT))
|
||||||
|
|
||||||
|
if dt.DirectoryList.spinner.IsRunning() {
|
||||||
|
dt.DirectoryList.spinner.Draw(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n := dt.countVisible(dt.list)
|
||||||
|
if n == 0 || dt.listIdx < 0 {
|
||||||
|
style := uiConfig.GetStyle(config.STYLE_DIRLIST_DEFAULT)
|
||||||
|
ctx.Printf(0, 0, style, "%s", uiConfig.EmptyDirlist)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dt.UpdateScroller(ctx.Height(), n)
|
||||||
|
dt.EnsureScroll(dt.countVisible(dt.list[:dt.listIdx]))
|
||||||
|
|
||||||
|
needScrollbar := true
|
||||||
|
percentVisible := float64(ctx.Height()) / float64(n)
|
||||||
|
if percentVisible >= 1.0 {
|
||||||
|
needScrollbar = false
|
||||||
|
}
|
||||||
|
|
||||||
|
textWidth := ctx.Width()
|
||||||
|
if needScrollbar {
|
||||||
|
textWidth -= 1
|
||||||
|
}
|
||||||
|
if textWidth < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
treeCtx := ctx.Subcontext(0, 0, textWidth, ctx.Height())
|
||||||
|
|
||||||
|
data := state.NewDataSetter()
|
||||||
|
data.SetAccount(dt.acctConf)
|
||||||
|
|
||||||
|
n = 0
|
||||||
|
for i, node := range dt.list {
|
||||||
|
if n > treeCtx.Height() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rowNr := dt.countVisible(dt.list[:i])
|
||||||
|
if rowNr < dt.Scroll() || !isVisible(node) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := dt.getDirectory(node)
|
||||||
|
dir := dt.Directory(path)
|
||||||
|
treeDir := &models.Directory{
|
||||||
|
Name: dt.displayText(node),
|
||||||
|
}
|
||||||
|
if dir != nil {
|
||||||
|
treeDir.Role = dir.Role
|
||||||
|
}
|
||||||
|
data.SetFolder(treeDir)
|
||||||
|
data.SetRUE([]string{path}, dt.GetRUECount)
|
||||||
|
|
||||||
|
left, right, style := dt.renderDir(
|
||||||
|
path, uiConfig, data.Data(),
|
||||||
|
i == dt.listIdx, treeCtx.Width(),
|
||||||
|
)
|
||||||
|
|
||||||
|
treeCtx.Printf(0, n, style, "%s %s", left, right)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
|
||||||
|
if dt.NeedScrollbar() {
|
||||||
|
scrollBarCtx := ctx.Subcontext(ctx.Width()-1, 0, 1, ctx.Height())
|
||||||
|
dt.drawScrollbar(scrollBarCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) MouseEvent(localX int, localY int, event vaxis.Event) {
|
||||||
|
if event, ok := event.(vaxis.Mouse); ok {
|
||||||
|
switch event.Button {
|
||||||
|
case vaxis.MouseLeftButton:
|
||||||
|
clickedDir, ok := dt.Clicked(localX, localY)
|
||||||
|
if ok {
|
||||||
|
dt.Select(clickedDir)
|
||||||
|
}
|
||||||
|
case vaxis.MouseWheelDown:
|
||||||
|
dt.NextPrev(1)
|
||||||
|
case vaxis.MouseWheelUp:
|
||||||
|
dt.NextPrev(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) Clicked(x int, y int) (string, bool) {
|
||||||
|
if len(dt.list) == 0 || dt.countVisible(dt.list) < y+dt.Scroll() {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
visible := 0
|
||||||
|
for _, node := range dt.list {
|
||||||
|
if isVisible(node) {
|
||||||
|
visible++
|
||||||
|
}
|
||||||
|
if visible == y+dt.Scroll()+1 {
|
||||||
|
if path := dt.getDirectory(node); path != "" {
|
||||||
|
return path, true
|
||||||
|
}
|
||||||
|
if node.Hidden == 0 {
|
||||||
|
node.Hidden = 1
|
||||||
|
} else {
|
||||||
|
node.Hidden = 0
|
||||||
|
}
|
||||||
|
dt.Invalidate()
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) SelectedMsgStore() (*lib.MessageStore, bool) {
|
||||||
|
if dt.virtual {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := models.UID(dt.selected)
|
||||||
|
if _, node := dt.getTreeNode(selected); node == nil {
|
||||||
|
dt.buildTree()
|
||||||
|
selIdx, node := dt.getTreeNode(selected)
|
||||||
|
if node != nil {
|
||||||
|
makeVisible(node)
|
||||||
|
dt.listIdx = selIdx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dt.DirectoryList.SelectedMsgStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) reindex(name string) {
|
||||||
|
selIdx, node := dt.getTreeNode(models.UID(name))
|
||||||
|
if node != nil {
|
||||||
|
makeVisible(node)
|
||||||
|
dt.listIdx = selIdx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) Select(name string) {
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dt.Open(name, "", dt.UiConfig(name).DirListDelay, nil, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) Open(name string, query string, delay time.Duration, cb func(types.WorkerMessage), force bool) {
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
again := false
|
||||||
|
uid := models.UID(name)
|
||||||
|
if _, node := dt.getTreeNode(uid); node == nil {
|
||||||
|
again = true
|
||||||
|
} else {
|
||||||
|
dt.reindex(name)
|
||||||
|
}
|
||||||
|
dt.DirectoryList.Open(name, query, delay, func(msg types.WorkerMessage) {
|
||||||
|
if cb != nil {
|
||||||
|
cb(msg)
|
||||||
|
}
|
||||||
|
if _, ok := msg.(*types.Done); ok && again {
|
||||||
|
if findString(dt.dirs, name) < 0 {
|
||||||
|
dt.dirs = append(dt.dirs, name)
|
||||||
|
}
|
||||||
|
dt.buildTree()
|
||||||
|
dt.reindex(name)
|
||||||
|
}
|
||||||
|
}, force)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) NextPrev(delta int) {
|
||||||
|
newIdx := dt.listIdx
|
||||||
|
ndirs := len(dt.list)
|
||||||
|
if newIdx == ndirs {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ndirs == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
step := 1
|
||||||
|
if delta < 0 {
|
||||||
|
step = -1
|
||||||
|
delta *= -1
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < delta; {
|
||||||
|
newIdx += step
|
||||||
|
if newIdx < 0 {
|
||||||
|
newIdx = ndirs - 1
|
||||||
|
} else if newIdx >= ndirs {
|
||||||
|
newIdx = 0
|
||||||
|
}
|
||||||
|
if isVisible(dt.list[newIdx]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dt.selectIndex(newIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) selectIndex(i int) {
|
||||||
|
dt.listIdx = i
|
||||||
|
node := dt.list[dt.listIdx]
|
||||||
|
if node.Dummy {
|
||||||
|
dt.virtual = true
|
||||||
|
dt.NewContext()
|
||||||
|
dt.virtualCb()
|
||||||
|
} else {
|
||||||
|
dt.virtual = false
|
||||||
|
dt.Select(dt.getDirectory(node))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) CollapseFolder(name string) {
|
||||||
|
name = strings.TrimRight(name, dt.worker.PathSeparator())
|
||||||
|
index, node := dt.getTreeNode(models.UID(name))
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if node.Parent != nil && (node.Hidden != 0 || node.FirstChild == nil) {
|
||||||
|
node.Parent.Hidden = 1
|
||||||
|
// highlight parent node and select it
|
||||||
|
for i, t := range dt.list {
|
||||||
|
if t == node.Parent && index == dt.listIdx {
|
||||||
|
dt.selectIndex(i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
node.Hidden = 1
|
||||||
|
}
|
||||||
|
dt.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) ExpandFolder(name string) {
|
||||||
|
name = strings.TrimRight(name, dt.worker.PathSeparator())
|
||||||
|
_, node := dt.getTreeNode(models.UID(name))
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node.Hidden = 0
|
||||||
|
dt.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) countVisible(list []*types.Thread) (n int) {
|
||||||
|
for _, node := range list {
|
||||||
|
if isVisible(node) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) nodeElems(node *types.Thread) []string {
|
||||||
|
dir := string(node.Uid)
|
||||||
|
sep := dt.DirectoryList.worker.PathSeparator()
|
||||||
|
return strings.Split(dir, sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) nodeName(node *types.Thread) string {
|
||||||
|
if elems := dt.nodeElems(node); len(elems) > 0 {
|
||||||
|
return elems[len(elems)-1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) displayText(node *types.Thread) string {
|
||||||
|
return fmt.Sprintf("%s%s%s",
|
||||||
|
threadPrefix(node, false, false),
|
||||||
|
getFlag(node), dt.nodeName(node))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) getDirectory(node *types.Thread) string {
|
||||||
|
return string(node.Uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) getTreeNode(uid models.UID) (int, *types.Thread) {
|
||||||
|
for i, node := range dt.list {
|
||||||
|
if node.Uid == uid {
|
||||||
|
return i, node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) hiddenDirectories() map[string]bool {
|
||||||
|
hidden := make(map[string]bool, 0)
|
||||||
|
for _, node := range dt.list {
|
||||||
|
if node.Hidden != 0 && node.FirstChild != nil {
|
||||||
|
elems := dt.nodeElems(node)
|
||||||
|
if levels := countLevels(node); levels < len(elems) {
|
||||||
|
if node.FirstChild != nil && (levels+1) < len(elems) {
|
||||||
|
levels += 1
|
||||||
|
}
|
||||||
|
if dirStr := strings.Join(elems[:levels], dt.DirectoryList.worker.PathSeparator()); dirStr != "" {
|
||||||
|
hidden[dirStr] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) setHiddenDirectories(hiddenDirs map[string]bool) {
|
||||||
|
log.Tracef("setHiddenDirectories: %#v", hiddenDirs)
|
||||||
|
for _, node := range dt.list {
|
||||||
|
elems := dt.nodeElems(node)
|
||||||
|
if levels := countLevels(node); levels < len(elems) {
|
||||||
|
if node.FirstChild != nil && (levels+1) < len(elems) {
|
||||||
|
levels += 1
|
||||||
|
}
|
||||||
|
strDir := strings.Join(elems[:levels], dt.DirectoryList.worker.PathSeparator())
|
||||||
|
if hidden, ok := hiddenDirs[strDir]; hidden && ok {
|
||||||
|
node.Hidden = 1
|
||||||
|
log.Tracef("setHiddenDirectories: %q -> %#v", strDir, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) buildTree() {
|
||||||
|
if len(dt.list) != 0 {
|
||||||
|
hiddenDirs := dt.hiddenDirectories()
|
||||||
|
defer dt.setHiddenDirectories(hiddenDirs)
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs := make([]string, len(dt.dirs))
|
||||||
|
copy(dirs, dt.dirs)
|
||||||
|
root := &types.Thread{}
|
||||||
|
dt.buildTreeNode(root, dirs, 1)
|
||||||
|
|
||||||
|
var threads []*types.Thread
|
||||||
|
for iter := root.FirstChild; iter != nil; iter = iter.NextSibling {
|
||||||
|
iter.Parent = nil
|
||||||
|
threads = append(threads, iter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// folders-sort
|
||||||
|
if dt.DirectoryList.acctConf.EnableFoldersSort {
|
||||||
|
sort.Slice(threads, func(i, j int) bool {
|
||||||
|
foldersSort := dt.DirectoryList.acctConf.FoldersSort
|
||||||
|
iInFoldersSort := findString(foldersSort, dt.getDirectory(threads[i]))
|
||||||
|
jInFoldersSort := findString(foldersSort, dt.getDirectory(threads[j]))
|
||||||
|
if iInFoldersSort >= 0 && jInFoldersSort >= 0 {
|
||||||
|
return iInFoldersSort < jInFoldersSort
|
||||||
|
}
|
||||||
|
if iInFoldersSort >= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if jInFoldersSort >= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return dt.getDirectory(threads[i]) < dt.getDirectory(threads[j])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dt.list = make([]*types.Thread, 0)
|
||||||
|
for _, node := range threads {
|
||||||
|
err := node.Walk(func(t *types.Thread, lvl int, err error) error {
|
||||||
|
dt.list = append(dt.list, t)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to walk tree: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) buildTreeNode(node *types.Thread, dirs []string, depth int) {
|
||||||
|
dirmap := make(map[string][]string)
|
||||||
|
for _, dir := range dirs {
|
||||||
|
base, dir, cut := strings.Cut(
|
||||||
|
dir, dt.DirectoryList.worker.PathSeparator())
|
||||||
|
if _, found := dirmap[base]; found {
|
||||||
|
if cut {
|
||||||
|
dirmap[base] = append(dirmap[base], dir)
|
||||||
|
}
|
||||||
|
} else if cut {
|
||||||
|
dirmap[base] = append(dirmap[base], dir)
|
||||||
|
} else {
|
||||||
|
dirmap[base] = []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bases := make([]string, 0, len(dirmap))
|
||||||
|
for base, dirs := range dirmap {
|
||||||
|
bases = append(bases, base)
|
||||||
|
sort.Strings(dirs)
|
||||||
|
}
|
||||||
|
sort.Strings(bases)
|
||||||
|
|
||||||
|
basePath := dt.getDirectory(node)
|
||||||
|
collapse := dt.UiConfig(basePath).DirListCollapse
|
||||||
|
if collapse != 0 && depth > collapse {
|
||||||
|
node.Hidden = 1
|
||||||
|
} else {
|
||||||
|
node.Hidden = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, base := range bases {
|
||||||
|
path := dt.childPath(basePath, base)
|
||||||
|
nextNode := &types.Thread{Uid: models.UID(path)}
|
||||||
|
|
||||||
|
nextNode.Dummy = findString(dt.dirs, path) == -1
|
||||||
|
|
||||||
|
node.AddChild(nextNode)
|
||||||
|
dt.buildTreeNode(nextNode, dirmap[base], depth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dt *DirectoryTree) childPath(base, relpath string) string {
|
||||||
|
if base == "" {
|
||||||
|
return relpath
|
||||||
|
}
|
||||||
|
return base + dt.DirectoryList.worker.PathSeparator() + relpath
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeVisible(node *types.Thread) {
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for iter := node.Parent; iter != nil; iter = iter.Parent {
|
||||||
|
iter.Hidden = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVisible(node *types.Thread) bool {
|
||||||
|
for iter := node.Parent; iter != nil; iter = iter.Parent {
|
||||||
|
if iter.Hidden != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func countLevels(node *types.Thread) (level int) {
|
||||||
|
for iter := node.Parent; iter != nil; iter = iter.Parent {
|
||||||
|
level++
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFlag(node *types.Thread) string {
|
||||||
|
if node == nil || node.FirstChild == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if node.Hidden != 0 {
|
||||||
|
return "+"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/go-opt/v2"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExLine struct {
|
||||||
|
commit func(cmd string)
|
||||||
|
finish func()
|
||||||
|
tabcomplete func(ctx context.Context, cmd string) ([]opt.Completion, string)
|
||||||
|
cmdHistory lib.History
|
||||||
|
input *ui.TextInput
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExLine(cmd string, commit func(cmd string), finish func(),
|
||||||
|
tabcomplete func(ctx context.Context, cmd string) ([]opt.Completion, string),
|
||||||
|
cmdHistory lib.History,
|
||||||
|
) *ExLine {
|
||||||
|
input := ui.NewTextInput("", config.Ui).Prompt(":").Set(cmd)
|
||||||
|
if config.Ui.CompletionPopovers {
|
||||||
|
input.TabComplete(
|
||||||
|
tabcomplete,
|
||||||
|
config.Ui.CompletionDelay,
|
||||||
|
config.Ui.CompletionMinChars,
|
||||||
|
&config.Binds.Global.CompleteKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
exline := &ExLine{
|
||||||
|
commit: commit,
|
||||||
|
finish: finish,
|
||||||
|
tabcomplete: tabcomplete,
|
||||||
|
cmdHistory: cmdHistory,
|
||||||
|
input: input,
|
||||||
|
}
|
||||||
|
return exline
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExLine) TabComplete(tabComplete func(context.Context, string) ([]opt.Completion, string)) {
|
||||||
|
x.input.TabComplete(
|
||||||
|
tabComplete,
|
||||||
|
config.Ui.CompletionDelay,
|
||||||
|
config.Ui.CompletionMinChars,
|
||||||
|
&config.Binds.Global.CompleteKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPrompt(prompt string, commit func(text string),
|
||||||
|
tabcomplete func(ctx context.Context, cmd string) ([]opt.Completion, string),
|
||||||
|
) *ExLine {
|
||||||
|
input := ui.NewTextInput("", config.Ui).Prompt(prompt)
|
||||||
|
if config.Ui.CompletionPopovers {
|
||||||
|
input.TabComplete(
|
||||||
|
tabcomplete,
|
||||||
|
config.Ui.CompletionDelay,
|
||||||
|
config.Ui.CompletionMinChars,
|
||||||
|
&config.Binds.Global.CompleteKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
exline := &ExLine{
|
||||||
|
commit: commit,
|
||||||
|
tabcomplete: tabcomplete,
|
||||||
|
cmdHistory: &nullHistory{input: input},
|
||||||
|
input: input,
|
||||||
|
}
|
||||||
|
return exline
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ex *ExLine) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ex *ExLine) Draw(ctx *ui.Context) {
|
||||||
|
ex.input.Draw(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ex *ExLine) Focus(focus bool) {
|
||||||
|
ex.input.Focus(focus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ex *ExLine) Event(event vaxis.Event) bool {
|
||||||
|
if key, ok := event.(vaxis.Key); ok {
|
||||||
|
switch {
|
||||||
|
case key.Matches(vaxis.KeyEnter), key.Matches('j', vaxis.ModCtrl):
|
||||||
|
cmd := ex.input.String()
|
||||||
|
ex.input.Focus(false)
|
||||||
|
ex.commit(cmd)
|
||||||
|
ex.finish()
|
||||||
|
case key.Matches(vaxis.KeyUp):
|
||||||
|
ex.input.Set(ex.cmdHistory.Prev())
|
||||||
|
ex.Invalidate()
|
||||||
|
case key.Matches(vaxis.KeyDown):
|
||||||
|
ex.input.Set(ex.cmdHistory.Next())
|
||||||
|
ex.Invalidate()
|
||||||
|
case key.Matches(vaxis.KeyEsc), key.Matches('c', vaxis.ModCtrl):
|
||||||
|
ex.input.Focus(false)
|
||||||
|
ex.cmdHistory.Reset()
|
||||||
|
ex.finish()
|
||||||
|
default:
|
||||||
|
return ex.input.Event(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type nullHistory struct {
|
||||||
|
input *ui.TextInput
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*nullHistory) Add(string) {}
|
||||||
|
|
||||||
|
func (h *nullHistory) Next() string {
|
||||||
|
return h.input.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *nullHistory) Prev() string {
|
||||||
|
return h.input.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*nullHistory) Reset() {}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetPasswd struct {
|
||||||
|
callback func(string, error)
|
||||||
|
title string
|
||||||
|
prompt string
|
||||||
|
input *ui.TextInput
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGetPasswd(
|
||||||
|
title string, prompt string, cb func(string, error),
|
||||||
|
) *GetPasswd {
|
||||||
|
getpasswd := &GetPasswd{
|
||||||
|
callback: cb,
|
||||||
|
title: title,
|
||||||
|
prompt: prompt,
|
||||||
|
input: ui.NewTextInput("", config.Ui).Password(true).Prompt("Password: "),
|
||||||
|
}
|
||||||
|
getpasswd.input.Focus(true)
|
||||||
|
return getpasswd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gp *GetPasswd) Draw(ctx *ui.Context) {
|
||||||
|
defaultStyle := config.Ui.GetStyle(config.STYLE_DEFAULT)
|
||||||
|
titleStyle := config.Ui.GetStyle(config.STYLE_TITLE)
|
||||||
|
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle)
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), 1, ' ', titleStyle)
|
||||||
|
ctx.Printf(1, 0, titleStyle, "%s", gp.title)
|
||||||
|
ctx.Printf(1, 1, defaultStyle, "%s", gp.prompt)
|
||||||
|
gp.input.Draw(ctx.Subcontext(1, 3, ctx.Width()-2, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gp *GetPasswd) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gp *GetPasswd) Event(event vaxis.Event) bool {
|
||||||
|
switch event := event.(type) {
|
||||||
|
case vaxis.Key:
|
||||||
|
switch {
|
||||||
|
case event.Matches(vaxis.KeyEnter):
|
||||||
|
gp.input.Focus(false)
|
||||||
|
gp.callback(gp.input.String(), nil)
|
||||||
|
case event.Matches(vaxis.KeyEsc):
|
||||||
|
gp.input.Focus(false)
|
||||||
|
gp.callback("", fmt.Errorf("no password provided"))
|
||||||
|
default:
|
||||||
|
gp.input.Event(event)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
gp.input.Event(event)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gp *GetPasswd) Focus(f bool) {
|
||||||
|
// Who cares
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HeaderLayout [][]string
|
||||||
|
|
||||||
|
type HeaderLayoutFilter struct {
|
||||||
|
layout HeaderLayout
|
||||||
|
keep func(msg *models.MessageInfo, header string) bool // filter criteria
|
||||||
|
}
|
||||||
|
|
||||||
|
// forMessage returns a filtered header layout, removing rows whose headers
|
||||||
|
// do not appear in the provided message.
|
||||||
|
func (filter HeaderLayoutFilter) forMessage(msg *models.MessageInfo) HeaderLayout {
|
||||||
|
result := make(HeaderLayout, 0, len(filter.layout))
|
||||||
|
for _, row := range filter.layout {
|
||||||
|
// To preserve layout alignment, only hide rows if all columns are empty
|
||||||
|
for _, col := range row {
|
||||||
|
if filter.keep(msg, col) {
|
||||||
|
result = append(result, row)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// grid builds a ui grid, populating each cell by calling a callback function
|
||||||
|
// with the current header string.
|
||||||
|
func (layout HeaderLayout) grid(cb func(string) ui.Drawable) (grid *ui.Grid, height int) {
|
||||||
|
rowCount := len(layout)
|
||||||
|
grid = ui.MakeGrid(rowCount, 1, ui.SIZE_EXACT, ui.SIZE_WEIGHT)
|
||||||
|
for i, cols := range layout {
|
||||||
|
r := ui.MakeGrid(1, len(cols), ui.SIZE_EXACT, ui.SIZE_WEIGHT)
|
||||||
|
for j, col := range cols {
|
||||||
|
r.AddChild(cb(col)).At(0, j)
|
||||||
|
}
|
||||||
|
grid.AddChild(r).At(i, 0)
|
||||||
|
}
|
||||||
|
return grid, rowCount
|
||||||
|
}
|
||||||
+325
@@ -0,0 +1,325 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListBox struct {
|
||||||
|
Scrollable
|
||||||
|
title string
|
||||||
|
lines []string
|
||||||
|
selected string
|
||||||
|
cursorPos int
|
||||||
|
horizPos int
|
||||||
|
jump int
|
||||||
|
showCursor bool
|
||||||
|
showFilter bool
|
||||||
|
filterMutex sync.Mutex
|
||||||
|
filter *ui.TextInput
|
||||||
|
uiConfig *config.UIConfig
|
||||||
|
textFilter func([]string, string) []string
|
||||||
|
cb func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListBox(title string, lines []string, uiConfig *config.UIConfig, cb func(string)) *ListBox {
|
||||||
|
lb := &ListBox{
|
||||||
|
title: title,
|
||||||
|
lines: lines,
|
||||||
|
cursorPos: -1,
|
||||||
|
jump: -1,
|
||||||
|
uiConfig: uiConfig,
|
||||||
|
textFilter: nil,
|
||||||
|
cb: cb,
|
||||||
|
filter: ui.NewTextInput("", uiConfig),
|
||||||
|
}
|
||||||
|
lb.filter.OnChange(func(ti *ui.TextInput) {
|
||||||
|
var show bool
|
||||||
|
if ti.String() == "" {
|
||||||
|
show = false
|
||||||
|
} else {
|
||||||
|
show = true
|
||||||
|
}
|
||||||
|
lb.setShowFilterField(show)
|
||||||
|
lb.filter.Focus(show)
|
||||||
|
lb.Invalidate()
|
||||||
|
})
|
||||||
|
lb.dedup()
|
||||||
|
return lb
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) SetTextFilter(fn func([]string, string) []string) *ListBox {
|
||||||
|
lb.textFilter = fn
|
||||||
|
return lb
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) dedup() {
|
||||||
|
dedupped := make([]string, 0, len(lb.lines))
|
||||||
|
dedup := make(map[string]struct{})
|
||||||
|
for _, line := range lb.lines {
|
||||||
|
if _, dup := dedup[line]; dup {
|
||||||
|
log.Warnf("ignore duplicate: %s", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dedup[line] = struct{}{}
|
||||||
|
dedupped = append(dedupped, line)
|
||||||
|
}
|
||||||
|
lb.lines = dedupped
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) setShowFilterField(b bool) {
|
||||||
|
lb.filterMutex.Lock()
|
||||||
|
defer lb.filterMutex.Unlock()
|
||||||
|
lb.showFilter = b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) showFilterField() bool {
|
||||||
|
lb.filterMutex.Lock()
|
||||||
|
defer lb.filterMutex.Unlock()
|
||||||
|
return lb.showFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) Draw(ctx *ui.Context) {
|
||||||
|
defaultStyle := lb.uiConfig.GetStyle(config.STYLE_DEFAULT)
|
||||||
|
titleStyle := lb.uiConfig.GetStyle(config.STYLE_TITLE)
|
||||||
|
w, h := ctx.Width(), ctx.Height()
|
||||||
|
ctx.Fill(0, 0, w, h, ' ', defaultStyle)
|
||||||
|
ctx.Fill(0, 0, w, 1, ' ', titleStyle)
|
||||||
|
ctx.Printf(0, 0, titleStyle, "%s", lb.title)
|
||||||
|
|
||||||
|
y := 0
|
||||||
|
if lb.showFilterField() {
|
||||||
|
y = 1
|
||||||
|
x := ctx.Printf(0, y, defaultStyle, "Filter (%d/%d): ",
|
||||||
|
len(lb.filtered()), len(lb.lines))
|
||||||
|
lb.filter.Draw(ctx.Subcontext(x, y, w-x, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
lb.drawBox(ctx.Subcontext(0, y+1, w, h-(y+1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) moveCursor(delta int) {
|
||||||
|
list := lb.filtered()
|
||||||
|
if len(list) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lb.cursorPos += delta
|
||||||
|
if lb.cursorPos < 0 {
|
||||||
|
lb.cursorPos = 0
|
||||||
|
}
|
||||||
|
if lb.cursorPos >= len(list) {
|
||||||
|
lb.cursorPos = len(list) - 1
|
||||||
|
}
|
||||||
|
lb.selected = list[lb.cursorPos]
|
||||||
|
lb.showCursor = true
|
||||||
|
lb.horizPos = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) moveHorizontal(delta int) {
|
||||||
|
lb.horizPos += delta
|
||||||
|
if lb.horizPos > len(lb.selected) {
|
||||||
|
lb.horizPos = len(lb.selected)
|
||||||
|
}
|
||||||
|
if lb.horizPos < 0 {
|
||||||
|
lb.horizPos = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) filtered() []string {
|
||||||
|
term := lb.filter.String()
|
||||||
|
|
||||||
|
if lb.textFilter != nil {
|
||||||
|
return lb.textFilter(lb.lines, term)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]string, 0, len(lb.lines))
|
||||||
|
for _, line := range lb.lines {
|
||||||
|
if strings.Contains(line, term) {
|
||||||
|
list = append(list, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) drawBox(ctx *ui.Context) {
|
||||||
|
defaultStyle := lb.uiConfig.GetStyle(config.STYLE_DEFAULT)
|
||||||
|
selectedStyle := lb.uiConfig.GetComposedStyleSelected(config.STYLE_MSGLIST_DEFAULT, nil)
|
||||||
|
|
||||||
|
w, h := ctx.Width(), ctx.Height()
|
||||||
|
lb.jump = h
|
||||||
|
list := lb.filtered()
|
||||||
|
|
||||||
|
lb.UpdateScroller(ctx.Height(), len(list))
|
||||||
|
scroll := 0
|
||||||
|
lb.cursorPos = -1
|
||||||
|
for i := 0; i < len(list); i++ {
|
||||||
|
if lb.selected == list[i] {
|
||||||
|
scroll = i
|
||||||
|
lb.cursorPos = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lb.EnsureScroll(scroll)
|
||||||
|
|
||||||
|
needScrollbar := lb.NeedScrollbar()
|
||||||
|
if needScrollbar {
|
||||||
|
w -= 1
|
||||||
|
if w < 0 {
|
||||||
|
w = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lb.lines == nil || len(list) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
y := 0
|
||||||
|
for i := lb.Scroll(); i < len(list) && y < h; i++ {
|
||||||
|
style := defaultStyle
|
||||||
|
line := runewidth.Truncate(list[i], w-1, "❯")
|
||||||
|
if lb.selected == list[i] && lb.showCursor {
|
||||||
|
style = selectedStyle
|
||||||
|
if len(list[i]) > w {
|
||||||
|
if len(list[i])-lb.horizPos < w {
|
||||||
|
lb.horizPos = len(list[i]) - w + 1
|
||||||
|
}
|
||||||
|
rest := list[i][lb.horizPos:]
|
||||||
|
line = runewidth.Truncate(rest,
|
||||||
|
w-1, "❯")
|
||||||
|
if lb.horizPos > 0 && len(line) > 0 {
|
||||||
|
line = "❮" + line[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.Printf(1, y, style, "%s", line)
|
||||||
|
y += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if needScrollbar {
|
||||||
|
scrollBarCtx := ctx.Subcontext(w, 0, 1, ctx.Height())
|
||||||
|
lb.drawScrollbar(scrollBarCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) drawScrollbar(ctx *ui.Context) {
|
||||||
|
gutterStyle := vaxis.Style{}
|
||||||
|
pillStyle := vaxis.Style{Attribute: vaxis.AttrReverse}
|
||||||
|
|
||||||
|
// gutter
|
||||||
|
h := ctx.Height()
|
||||||
|
ctx.Fill(0, 0, 1, h, ' ', gutterStyle)
|
||||||
|
|
||||||
|
// pill
|
||||||
|
pillSize := int(math.Ceil(float64(h) * lb.PercentVisible()))
|
||||||
|
pillOffset := int(math.Floor(float64(h) * lb.PercentScrolled()))
|
||||||
|
ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) Event(event vaxis.Event) bool {
|
||||||
|
showFilter := lb.showFilterField()
|
||||||
|
if key, ok := event.(vaxis.Key); ok {
|
||||||
|
switch {
|
||||||
|
case key.Matches(vaxis.KeyLeft):
|
||||||
|
if showFilter {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lb.moveHorizontal(-1)
|
||||||
|
lb.Invalidate()
|
||||||
|
return true
|
||||||
|
case key.Matches(vaxis.KeyRight):
|
||||||
|
if showFilter {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lb.moveHorizontal(+1)
|
||||||
|
lb.Invalidate()
|
||||||
|
return true
|
||||||
|
case key.Matches('b', vaxis.ModCtrl):
|
||||||
|
line := lb.selected[:lb.horizPos]
|
||||||
|
fds := strings.Fields(line)
|
||||||
|
if len(fds) > 1 {
|
||||||
|
lb.moveHorizontal(
|
||||||
|
strings.LastIndex(line,
|
||||||
|
fds[len(fds)-1]) - lb.horizPos - 1)
|
||||||
|
} else {
|
||||||
|
lb.horizPos = 0
|
||||||
|
}
|
||||||
|
lb.Invalidate()
|
||||||
|
return true
|
||||||
|
case key.Matches('w', vaxis.ModCtrl):
|
||||||
|
line := lb.selected[lb.horizPos+1:]
|
||||||
|
fds := strings.Fields(line)
|
||||||
|
if len(fds) > 1 {
|
||||||
|
lb.moveHorizontal(strings.Index(line, fds[1]))
|
||||||
|
}
|
||||||
|
lb.Invalidate()
|
||||||
|
return true
|
||||||
|
case key.Matches('a', vaxis.ModCtrl), key.Matches(vaxis.KeyHome):
|
||||||
|
if showFilter {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lb.horizPos = 0
|
||||||
|
lb.Invalidate()
|
||||||
|
return true
|
||||||
|
case key.Matches('e', vaxis.ModCtrl), key.Matches(vaxis.KeyEnd):
|
||||||
|
if showFilter {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lb.horizPos = len(lb.selected)
|
||||||
|
lb.Invalidate()
|
||||||
|
return true
|
||||||
|
case key.Matches('p', vaxis.ModCtrl), key.Matches(vaxis.KeyUp):
|
||||||
|
lb.moveCursor(-1)
|
||||||
|
lb.Invalidate()
|
||||||
|
return true
|
||||||
|
case key.Matches('n', vaxis.ModCtrl), key.Matches(vaxis.KeyDown):
|
||||||
|
lb.moveCursor(+1)
|
||||||
|
lb.Invalidate()
|
||||||
|
return true
|
||||||
|
case key.Matches(vaxis.KeyPgUp):
|
||||||
|
if lb.jump >= 0 {
|
||||||
|
lb.moveCursor(-lb.jump)
|
||||||
|
lb.Invalidate()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case key.Matches(vaxis.KeyPgDown):
|
||||||
|
if lb.jump >= 0 {
|
||||||
|
lb.moveCursor(+lb.jump)
|
||||||
|
lb.Invalidate()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case key.Matches(vaxis.KeyEnter):
|
||||||
|
return lb.quit(lb.selected)
|
||||||
|
case key.Matches(vaxis.KeyEsc):
|
||||||
|
return lb.quit("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lb.filter != nil {
|
||||||
|
handled := lb.filter.Event(event)
|
||||||
|
lb.Invalidate()
|
||||||
|
return handled
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) quit(s string) bool {
|
||||||
|
lb.filter.Focus(false)
|
||||||
|
if lb.cb != nil {
|
||||||
|
lb.cb(s)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) Focus(f bool) {
|
||||||
|
lb.filter.Focus(f)
|
||||||
|
}
|
||||||
+602
@@ -0,0 +1,602 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sortthread "github.com/emersion/go-imap-sortthread"
|
||||||
|
"github.com/emersion/go-message/mail"
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MessageList struct {
|
||||||
|
Scrollable
|
||||||
|
height int
|
||||||
|
width int
|
||||||
|
nmsgs int
|
||||||
|
spinner *Spinner
|
||||||
|
store *lib.MessageStore
|
||||||
|
isInitalizing bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMessageList(account *AccountView) *MessageList {
|
||||||
|
ml := &MessageList{
|
||||||
|
spinner: NewSpinner(account.UiConfig()),
|
||||||
|
isInitalizing: true,
|
||||||
|
}
|
||||||
|
// TODO: stop spinner, probably
|
||||||
|
ml.spinner.Start()
|
||||||
|
return ml
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageRowParams struct {
|
||||||
|
uid models.UID
|
||||||
|
needsHeaders bool
|
||||||
|
err error
|
||||||
|
uiConfig *config.UIConfig
|
||||||
|
styles []config.StyleObject
|
||||||
|
headers *mail.Header
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlignMessage aligns the selected message to position pos.
|
||||||
|
func (ml *MessageList) AlignMessage(pos AlignPosition) {
|
||||||
|
store := ml.Store()
|
||||||
|
if store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := 0
|
||||||
|
iter := store.UidsIterator()
|
||||||
|
for i := 0; iter.Next(); i++ {
|
||||||
|
if store.SelectedUid() == iter.Value().(models.UID) {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ml.Align(idx, pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) Draw(ctx *ui.Context) {
|
||||||
|
ml.height = ctx.Height()
|
||||||
|
ml.width = ctx.Width()
|
||||||
|
uiConfig := SelectedAccountUiConfig()
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
|
||||||
|
uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT))
|
||||||
|
|
||||||
|
acct := SelectedAccount()
|
||||||
|
store := ml.Store()
|
||||||
|
if store == nil || acct == nil || len(store.Uids()) == 0 {
|
||||||
|
if ml.isInitalizing {
|
||||||
|
ml.spinner.Draw(ctx)
|
||||||
|
} else {
|
||||||
|
ml.spinner.Stop()
|
||||||
|
ml.drawEmptyMessage(ctx)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ml.SetOffset(uiConfig.MsglistScrollOffset)
|
||||||
|
ml.UpdateScroller(ml.height, len(store.Uids()))
|
||||||
|
iter := store.UidsIterator()
|
||||||
|
for i := 0; iter.Next(); i++ {
|
||||||
|
if store.SelectedUid() == iter.Value().(models.UID) {
|
||||||
|
ml.EnsureScroll(i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.UpdateScroll(ml.Scroll(), ml.height)
|
||||||
|
|
||||||
|
textWidth := ctx.Width()
|
||||||
|
if ml.NeedScrollbar() {
|
||||||
|
textWidth -= 1
|
||||||
|
}
|
||||||
|
if textWidth <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var needsHeaders []models.UID
|
||||||
|
|
||||||
|
data := state.NewDataSetter()
|
||||||
|
data.SetAccount(acct.acct)
|
||||||
|
data.SetFolder(acct.Directories().SelectedDirectory())
|
||||||
|
|
||||||
|
customDraw := func(t *ui.Table, r int, c *ui.Context) bool {
|
||||||
|
row := &t.Rows[r]
|
||||||
|
params, _ := row.Priv.(messageRowParams)
|
||||||
|
if params.err != nil {
|
||||||
|
var style vaxis.Style
|
||||||
|
if params.uid == store.SelectedUid() {
|
||||||
|
style = uiConfig.GetStyle(config.STYLE_ERROR)
|
||||||
|
} else {
|
||||||
|
style = uiConfig.GetStyleSelected(config.STYLE_ERROR)
|
||||||
|
}
|
||||||
|
ctx.Printf(0, r, style, "error: %s", params.err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if params.needsHeaders {
|
||||||
|
needsHeaders = append(needsHeaders, params.uid)
|
||||||
|
ml.spinner.Draw(ctx.Subcontext(0, r, c.Width(), 1))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
getRowStyle := func(t *ui.Table, r int) vaxis.Style {
|
||||||
|
var style vaxis.Style
|
||||||
|
row := &t.Rows[r]
|
||||||
|
params, _ := row.Priv.(messageRowParams)
|
||||||
|
if params.uid == store.SelectedUid() {
|
||||||
|
style = params.uiConfig.MsgComposedStyleSelected(
|
||||||
|
config.STYLE_MSGLIST_DEFAULT, params.styles,
|
||||||
|
params.headers)
|
||||||
|
} else {
|
||||||
|
style = params.uiConfig.MsgComposedStyle(
|
||||||
|
config.STYLE_MSGLIST_DEFAULT, params.styles,
|
||||||
|
params.headers)
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
table := ui.NewTable(
|
||||||
|
ml.height,
|
||||||
|
uiConfig.IndexColumns,
|
||||||
|
uiConfig.ColumnSeparator,
|
||||||
|
customDraw,
|
||||||
|
getRowStyle,
|
||||||
|
)
|
||||||
|
|
||||||
|
showThreads := store.ThreadedView()
|
||||||
|
threadView := newThreadView(store)
|
||||||
|
iter = store.UidsIterator()
|
||||||
|
for i := 0; iter.Next(); i++ {
|
||||||
|
if i < ml.Scroll() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uid := iter.Value().(models.UID)
|
||||||
|
if showThreads {
|
||||||
|
threadView.Update(data, uid)
|
||||||
|
}
|
||||||
|
if addMessage(store, uid, &table, data, uiConfig) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Draw(ctx.Subcontext(0, 0, textWidth, ctx.Height()))
|
||||||
|
|
||||||
|
if ml.NeedScrollbar() {
|
||||||
|
scrollbarCtx := ctx.Subcontext(textWidth, 0, 1, ctx.Height())
|
||||||
|
ml.drawScrollbar(scrollbarCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(store.Uids()) == 0 {
|
||||||
|
if store.Sorting {
|
||||||
|
ml.spinner.Start()
|
||||||
|
ml.spinner.Draw(ctx)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
ml.drawEmptyMessage(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(needsHeaders) != 0 {
|
||||||
|
store.FetchHeaders(needsHeaders, nil)
|
||||||
|
ml.spinner.Start()
|
||||||
|
} else {
|
||||||
|
ml.spinner.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addMessage(
|
||||||
|
store *lib.MessageStore, uid models.UID,
|
||||||
|
table *ui.Table, data state.DataSetter,
|
||||||
|
uiConfig *config.UIConfig,
|
||||||
|
) bool {
|
||||||
|
msg := store.Messages[uid]
|
||||||
|
|
||||||
|
cells := make([]string, len(table.Columns))
|
||||||
|
params := messageRowParams{uid: uid, uiConfig: uiConfig}
|
||||||
|
|
||||||
|
if msg == nil || (msg.Envelope == nil && msg.Error == nil) {
|
||||||
|
params.needsHeaders = true
|
||||||
|
return table.AddRow(cells, params)
|
||||||
|
} else if msg.Error != nil {
|
||||||
|
params.err = msg.Error
|
||||||
|
return table.AddRow(cells, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Flags.Has(models.SeenFlag) {
|
||||||
|
params.styles = append(params.styles, config.STYLE_MSGLIST_READ)
|
||||||
|
} else {
|
||||||
|
params.styles = append(params.styles, config.STYLE_MSGLIST_UNREAD)
|
||||||
|
}
|
||||||
|
if msg.Flags.Has(models.AnsweredFlag) {
|
||||||
|
params.styles = append(params.styles, config.STYLE_MSGLIST_ANSWERED)
|
||||||
|
}
|
||||||
|
if msg.Flags.Has(models.ForwardedFlag) {
|
||||||
|
params.styles = append(params.styles, config.STYLE_MSGLIST_FORWARDED)
|
||||||
|
}
|
||||||
|
if msg.Flags.Has(models.FlaggedFlag) {
|
||||||
|
params.styles = append(params.styles, config.STYLE_MSGLIST_FLAGGED)
|
||||||
|
}
|
||||||
|
// deleted message
|
||||||
|
if _, ok := store.Deleted[msg.Uid]; ok {
|
||||||
|
params.styles = append(params.styles, config.STYLE_MSGLIST_DELETED)
|
||||||
|
}
|
||||||
|
// search result
|
||||||
|
if store.IsResult(msg.Uid) {
|
||||||
|
params.styles = append(params.styles, config.STYLE_MSGLIST_RESULT)
|
||||||
|
}
|
||||||
|
// folded thread
|
||||||
|
templateData, ok := data.(models.TemplateData)
|
||||||
|
if ok {
|
||||||
|
if templateData.ThreadFolded() {
|
||||||
|
params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_FOLDED)
|
||||||
|
}
|
||||||
|
if templateData.ThreadContext() {
|
||||||
|
params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_CONTEXT)
|
||||||
|
}
|
||||||
|
if templateData.ThreadOrphan() {
|
||||||
|
params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_ORPHAN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// marked message
|
||||||
|
marked := store.Marker().IsMarked(msg.Uid)
|
||||||
|
if marked {
|
||||||
|
params.styles = append(params.styles, config.STYLE_MSGLIST_MARKED)
|
||||||
|
}
|
||||||
|
|
||||||
|
data.SetInfo(msg, len(table.Rows), marked)
|
||||||
|
|
||||||
|
for c, col := range table.Columns {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := col.Def.Template.Execute(&buf, data.Data())
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("<%s> %s", msg.Envelope.MessageId, err)
|
||||||
|
cells[c] = err.Error()
|
||||||
|
} else {
|
||||||
|
cells[c] = buf.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
params.headers = msg.RFC822Headers
|
||||||
|
|
||||||
|
return table.AddRow(cells, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) drawScrollbar(ctx *ui.Context) {
|
||||||
|
uiConfig := SelectedAccountUiConfig()
|
||||||
|
gutterStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_GUTTER)
|
||||||
|
pillStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_PILL)
|
||||||
|
|
||||||
|
// gutter
|
||||||
|
ctx.Fill(0, 0, 1, ctx.Height(), ' ', gutterStyle)
|
||||||
|
|
||||||
|
// pill
|
||||||
|
pillSize := int(math.Ceil(float64(ctx.Height()) * ml.PercentVisible()))
|
||||||
|
pillOffset := int(math.Floor(float64(ctx.Height()) * ml.PercentScrolled()))
|
||||||
|
ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) MouseEvent(localX int, localY int, event vaxis.Event) {
|
||||||
|
if event, ok := event.(vaxis.Mouse); ok {
|
||||||
|
switch event.Button {
|
||||||
|
case vaxis.MouseLeftButton:
|
||||||
|
selectedMsg, ok := ml.Clicked(localX, localY)
|
||||||
|
if ok {
|
||||||
|
ml.Select(selectedMsg)
|
||||||
|
acct := SelectedAccount()
|
||||||
|
if acct == nil || acct.Messages().Empty() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store := acct.Messages().Store()
|
||||||
|
msg := acct.Messages().Selected()
|
||||||
|
if msg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lib.NewMessageStoreView(msg, acct.UiConfig().AutoMarkRead,
|
||||||
|
store, CryptoProvider(), DecryptKeys,
|
||||||
|
func(view lib.MessageView, err error) {
|
||||||
|
if err != nil {
|
||||||
|
PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewer, err := NewMessageViewer(acct, view)
|
||||||
|
if err != nil {
|
||||||
|
PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
NewTab(viewer, msg.Envelope.Subject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case vaxis.MouseWheelDown:
|
||||||
|
if ml.store != nil {
|
||||||
|
ml.store.Next()
|
||||||
|
}
|
||||||
|
ml.Invalidate()
|
||||||
|
case vaxis.MouseWheelUp:
|
||||||
|
if ml.store != nil {
|
||||||
|
ml.store.Prev()
|
||||||
|
}
|
||||||
|
ml.Invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) Clicked(x, y int) (int, bool) {
|
||||||
|
store := ml.Store()
|
||||||
|
if store == nil || ml.nmsgs == 0 || y >= ml.nmsgs {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return y + ml.Scroll(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) Height() int {
|
||||||
|
return ml.height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) Width() int {
|
||||||
|
return ml.width
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) storeUpdate(store *lib.MessageStore) {
|
||||||
|
if ml.Store() != store {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ml.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) SetStore(store *lib.MessageStore) {
|
||||||
|
if ml.Store() != store {
|
||||||
|
ml.Scrollable = Scrollable{}
|
||||||
|
}
|
||||||
|
ml.store = store
|
||||||
|
if store != nil {
|
||||||
|
ml.spinner.Stop()
|
||||||
|
uids := store.Uids()
|
||||||
|
ml.nmsgs = len(uids)
|
||||||
|
store.OnUpdate(ml.storeUpdate)
|
||||||
|
store.OnFilterChange(func(store *lib.MessageStore) {
|
||||||
|
if ml.Store() != store {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ml.nmsgs = len(store.Uids())
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ml.spinner.Start()
|
||||||
|
}
|
||||||
|
ml.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) SetInitDone() {
|
||||||
|
ml.isInitalizing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) Store() *lib.MessageStore {
|
||||||
|
return ml.store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) Empty() bool {
|
||||||
|
store := ml.Store()
|
||||||
|
return store == nil || len(store.Uids()) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) Selected() *models.MessageInfo {
|
||||||
|
return ml.Store().Selected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) Select(index int) {
|
||||||
|
// Note that the msgstore.Select function expects a uid as argument
|
||||||
|
// whereas the msglist.Select expects the message number
|
||||||
|
store := ml.Store()
|
||||||
|
uids := store.Uids()
|
||||||
|
if len(uids) == 0 {
|
||||||
|
store.Select(lib.MagicUid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
iter := store.UidsIterator()
|
||||||
|
|
||||||
|
var uid models.UID
|
||||||
|
if index < 0 {
|
||||||
|
uid = uids[iter.EndIndex()]
|
||||||
|
} else {
|
||||||
|
uid = uids[iter.StartIndex()]
|
||||||
|
for i := 0; iter.Next(); i++ {
|
||||||
|
if i >= index {
|
||||||
|
uid = iter.Value().(models.UID)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.Select(uid)
|
||||||
|
|
||||||
|
ml.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MessageList) drawEmptyMessage(ctx *ui.Context) {
|
||||||
|
uiConfig := SelectedAccountUiConfig()
|
||||||
|
msg := uiConfig.EmptyMessage
|
||||||
|
ctx.Printf((ctx.Width()/2)-(len(msg)/2), 0,
|
||||||
|
uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT), "%s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countThreads(thread *types.Thread) (ctr int) {
|
||||||
|
if thread == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = thread.Walk(func(t *types.Thread, _ int, _ error) error {
|
||||||
|
ctr++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func unreadInThread(thread *types.Thread, store *lib.MessageStore) (ctr int) {
|
||||||
|
if thread == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = thread.Walk(func(t *types.Thread, _ int, _ error) error {
|
||||||
|
msg := store.Messages[t.Uid]
|
||||||
|
if msg != nil && !msg.Flags.Has(models.SeenFlag) {
|
||||||
|
ctr++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func threadPrefix(t *types.Thread, reverse bool, msglist bool) string {
|
||||||
|
uiConfig := SelectedAccountUiConfig()
|
||||||
|
var tip, prefix, firstChild, lastSibling, orphan, dummy string
|
||||||
|
if msglist {
|
||||||
|
tip = uiConfig.ThreadPrefixTip
|
||||||
|
} else {
|
||||||
|
threadPrefixSibling := "├─"
|
||||||
|
threadPrefixReverse := "┌─"
|
||||||
|
threadPrefixEnd := "└─"
|
||||||
|
threadStem := "│"
|
||||||
|
threadIndent := strings.Repeat(" ", runewidth.StringWidth(threadPrefixSibling)-1)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case t.Parent != nil && t.NextSibling != nil:
|
||||||
|
prefix += threadPrefixSibling
|
||||||
|
case t.Parent != nil && reverse:
|
||||||
|
prefix += threadPrefixReverse
|
||||||
|
case t.Parent != nil:
|
||||||
|
prefix += threadPrefixEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
for n := t.Parent; n != nil && n.Parent != nil; n = n.Parent {
|
||||||
|
if n.NextSibling != nil {
|
||||||
|
prefix = threadStem + threadIndent + prefix
|
||||||
|
} else {
|
||||||
|
prefix = " " + threadIndent + prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
if reverse {
|
||||||
|
firstChild = uiConfig.ThreadPrefixFirstChildReverse
|
||||||
|
lastSibling = uiConfig.ThreadPrefixLastSiblingReverse
|
||||||
|
orphan = uiConfig.ThreadPrefixOrphanReverse
|
||||||
|
dummy = uiConfig.ThreadPrefixDummyReverse
|
||||||
|
} else {
|
||||||
|
firstChild = uiConfig.ThreadPrefixFirstChild
|
||||||
|
lastSibling = uiConfig.ThreadPrefixLastSibling
|
||||||
|
orphan = uiConfig.ThreadPrefixOrphan
|
||||||
|
dummy = uiConfig.ThreadPrefixDummy
|
||||||
|
}
|
||||||
|
|
||||||
|
var hiddenOffspring bool = t.FirstChild != nil && t.FirstChild.Hidden > 0
|
||||||
|
var parentAndSiblings bool = t.Parent != nil && t.NextSibling != nil
|
||||||
|
var hasSiblings string = uiConfig.ThreadPrefixHasSiblings
|
||||||
|
if t.Parent != nil && t.Parent.Hidden > 0 && t.Hidden == 0 {
|
||||||
|
hasSiblings = dummy
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case parentAndSiblings && hiddenOffspring:
|
||||||
|
prefix = hasSiblings +
|
||||||
|
uiConfig.ThreadPrefixFolded
|
||||||
|
case parentAndSiblings && t.FirstChild != nil:
|
||||||
|
prefix = hasSiblings +
|
||||||
|
firstChild + tip
|
||||||
|
case parentAndSiblings:
|
||||||
|
prefix = hasSiblings +
|
||||||
|
uiConfig.ThreadPrefixLimb +
|
||||||
|
uiConfig.ThreadPrefixUnfolded + tip
|
||||||
|
case t.Parent != nil && hiddenOffspring:
|
||||||
|
prefix = lastSibling + uiConfig.ThreadPrefixFolded
|
||||||
|
case t.Parent != nil && t.FirstChild != nil:
|
||||||
|
prefix = lastSibling + firstChild + tip
|
||||||
|
case t.Parent != nil && t.FirstChild == nil:
|
||||||
|
prefix = lastSibling + uiConfig.ThreadPrefixLimb + tip
|
||||||
|
case t.Parent != nil:
|
||||||
|
prefix = lastSibling + uiConfig.ThreadPrefixUnfolded +
|
||||||
|
uiConfig.ThreadPrefixTip
|
||||||
|
case t.Parent == nil && hiddenOffspring:
|
||||||
|
prefix = uiConfig.ThreadPrefixFolded
|
||||||
|
case t.Parent == nil && t.Dummy:
|
||||||
|
prefix = dummy + tip
|
||||||
|
case t.Parent == nil && t.FirstChild != nil:
|
||||||
|
prefix = orphan
|
||||||
|
case t.Parent == nil && t.FirstChild == nil:
|
||||||
|
prefix = uiConfig.ThreadPrefixLone
|
||||||
|
}
|
||||||
|
|
||||||
|
for n := t.Parent; n != nil && n.Parent != nil; n = n.Parent {
|
||||||
|
if n.NextSibling != nil {
|
||||||
|
prefix = uiConfig.ThreadPrefixStem +
|
||||||
|
uiConfig.ThreadPrefixIndent + prefix
|
||||||
|
} else {
|
||||||
|
prefix = " " + uiConfig.ThreadPrefixIndent + prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameParent(left, right *types.Thread) bool {
|
||||||
|
return left.Root() == right.Root()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isParent(t *types.Thread) bool {
|
||||||
|
return t == t.Root()
|
||||||
|
}
|
||||||
|
|
||||||
|
func threadSubject(store *lib.MessageStore, thread *types.Thread) string {
|
||||||
|
msg, found := store.Messages[thread.Uid]
|
||||||
|
if !found || msg == nil || msg.Envelope == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
subject, _ := sortthread.GetBaseSubject(msg.Envelope.Subject)
|
||||||
|
return subject
|
||||||
|
}
|
||||||
|
|
||||||
|
type threadView struct {
|
||||||
|
store *lib.MessageStore
|
||||||
|
reverse bool
|
||||||
|
prev *types.Thread
|
||||||
|
prevSubj string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newThreadView(store *lib.MessageStore) *threadView {
|
||||||
|
return &threadView{
|
||||||
|
store: store,
|
||||||
|
reverse: store.ReverseThreadOrder(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *threadView) Update(data state.DataSetter, uid models.UID) {
|
||||||
|
thread, err := t.store.Thread(uid)
|
||||||
|
info := state.ThreadInfo{}
|
||||||
|
if thread != nil && err == nil {
|
||||||
|
info.Prefix = threadPrefix(thread, t.reverse, true)
|
||||||
|
subject := threadSubject(t.store, thread)
|
||||||
|
info.SameSubject = subject == t.prevSubj && sameParent(thread, t.prev) && !isParent(thread)
|
||||||
|
t.prev = thread
|
||||||
|
t.prevSubj = subject
|
||||||
|
info.Count = countThreads(thread)
|
||||||
|
info.Unread = unreadInThread(thread, t.store)
|
||||||
|
info.Folded = thread.FirstChild != nil && thread.FirstChild.Hidden != 0
|
||||||
|
info.Context = thread.Context
|
||||||
|
info.Orphan = thread.Parent != nil && thread.Parent.Hidden > 0 && thread.Hidden == 0
|
||||||
|
}
|
||||||
|
data.SetThreading(info)
|
||||||
|
}
|
||||||
@@ -0,0 +1,914 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/danwakefield/fnmatch"
|
||||||
|
"github.com/emersion/go-message/textproto"
|
||||||
|
"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/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/parse"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/go-opt/v2"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
"git.sr.ht/~rockorager/vaxis/widgets/align"
|
||||||
|
|
||||||
|
// Image support
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
|
||||||
|
_ "golang.org/x/image/bmp"
|
||||||
|
_ "golang.org/x/image/tiff"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// All imported image types need to be explicitly stated here. We want to check
|
||||||
|
// if we _can_ display something before we download it
|
||||||
|
var supportedImageTypes = []string{
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/bmp",
|
||||||
|
"image/tiff",
|
||||||
|
"image/webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ProvidesMessages = (*MessageViewer)(nil)
|
||||||
|
|
||||||
|
type MessageViewer struct {
|
||||||
|
acct *AccountView
|
||||||
|
grid *ui.Grid
|
||||||
|
switcher *PartSwitcher
|
||||||
|
msg lib.MessageView
|
||||||
|
uiConfig *config.UIConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMessageViewer(
|
||||||
|
acct *AccountView, msg lib.MessageView,
|
||||||
|
) (*MessageViewer, error) {
|
||||||
|
if msg == nil {
|
||||||
|
return &MessageViewer{acct: acct}, nil
|
||||||
|
}
|
||||||
|
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 nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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.uiConfig = mv.uiConfig
|
||||||
|
|
||||||
|
return mv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "Sender":
|
||||||
|
return format.FormatAddresses(msg.Envelope.Sender)
|
||||||
|
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
|
||||||
|
|
||||||
|
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.switcher == nil {
|
||||||
|
style := mv.acct.UiConfig().GetStyle(config.STYLE_DEFAULT)
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
|
||||||
|
ctx.Printf(0, 0, style, "%s", "(no message selected)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mv.grid.Draw(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mv *MessageViewer) MouseEvent(localX int, localY int, event vaxis.Event) {
|
||||||
|
if mv.switcher == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mv.grid.MouseEvent(localX, localY, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mv *MessageViewer) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mv *MessageViewer) Terminal() *Terminal {
|
||||||
|
if mv.switcher == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nparts := len(mv.switcher.parts)
|
||||||
|
if nparts == 0 || mv.switcher.selected < 0 || mv.switcher.selected >= nparts {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pv := mv.switcher.parts[mv.switcher.selected]
|
||||||
|
if pv == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return pv.term
|
||||||
|
}
|
||||||
|
|
||||||
|
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() ([]models.UID, error) {
|
||||||
|
return mv.acct.MarkedMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mv *MessageViewer) ToggleHeaders() {
|
||||||
|
if mv.switcher == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
if mv.switcher == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
part := mv.switcher.SelectedPart()
|
||||||
|
return &PartInfo{
|
||||||
|
Index: part.index,
|
||||||
|
Msg: part.msg.MessageInfo(),
|
||||||
|
Part: part.part,
|
||||||
|
Links: part.links,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mv *MessageViewer) AttachmentParts(all bool) []*PartInfo {
|
||||||
|
if mv.switcher == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mv.switcher.AttachmentParts(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mv *MessageViewer) PreviousPart() {
|
||||||
|
if mv.switcher == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mv.switcher.PreviousPart()
|
||||||
|
mv.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mv *MessageViewer) NextPart() {
|
||||||
|
if mv.switcher == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mv.switcher.NextPart()
|
||||||
|
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 (mv *MessageViewer) Event(event vaxis.Event) bool {
|
||||||
|
if mv.switcher != nil {
|
||||||
|
return mv.switcher.Event(event)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mv *MessageViewer) Focus(focus bool) {
|
||||||
|
if mv.switcher != nil {
|
||||||
|
mv.switcher.Focus(focus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mv *MessageViewer) Show(visible bool) {
|
||||||
|
if mv.switcher != nil {
|
||||||
|
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
|
||||||
|
inlineImg bool
|
||||||
|
image image.Image
|
||||||
|
graphic vaxis.Image
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
case config.FILTER_FILENAME:
|
||||||
|
if f.Regex.Match([]byte(part.DispositionParams["filename"])) {
|
||||||
|
filter = exec.Command("sh", "-c", f.Command)
|
||||||
|
log.Tracef("command %v", f.Command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filter == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !f.NeedsPager {
|
||||||
|
pager = filter
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pagerCmd, err := CmdFallbackSearch(config.PagerCmds(), false)
|
||||||
|
if err != nil {
|
||||||
|
acct.PushError(fmt.Errorf("could not start pager: %w", err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd := opt.SplitArgs(pagerCmd)
|
||||||
|
pager = exec.Command(cmd[0], cmd[1:]...)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
if pager == filter {
|
||||||
|
log.Debugf("<%s> part=%v %s: %v",
|
||||||
|
info.Envelope.MessageId, curindex, mime, filter)
|
||||||
|
} else {
|
||||||
|
log.Debugf("<%s> part=%v %s: %v | %v",
|
||||||
|
info.Envelope.MessageId, curindex, mime, filter, pager)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return pv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pv *PartViewer) SetSource(reader io.Reader) {
|
||||||
|
pv.source = reader
|
||||||
|
switch pv.inlineImg {
|
||||||
|
case true:
|
||||||
|
pv.decodeImage()
|
||||||
|
default:
|
||||||
|
pv.attemptCopy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pv *PartViewer) decodeImage() {
|
||||||
|
atomic.StoreInt32(&pv.copying, copying)
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
defer pv.Invalidate()
|
||||||
|
defer atomic.StoreInt32(&pv.copying, 0)
|
||||||
|
img, _, err := image.Decode(pv.source)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("error decoding image: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pv.image = img
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pv *PartViewer) attemptCopy() {
|
||||||
|
if pv.source == nil ||
|
||||||
|
pv.filter == nil ||
|
||||||
|
atomic.SwapInt32(&pv.copying, copying) == copying {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pv.writeMailHeaders()
|
||||||
|
if strings.EqualFold(pv.part.MIMEType, "text") {
|
||||||
|
pv.source = parse.StripAnsi(pv.hyperlinks(pv.source))
|
||||||
|
}
|
||||||
|
if pv.filter != pv.pager {
|
||||||
|
// Filter is a separate process that needs to output to the pager.
|
||||||
|
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)
|
||||||
|
var err error
|
||||||
|
if pv.filter == pv.pager {
|
||||||
|
// Filter already implements its own paging.
|
||||||
|
_, err = io.Copy(pv.pagerin, pv.source)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("io.Copy: %s", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err = pv.filter.Wait()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("filter.Wait: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = pv.pagerin.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("error closing pager pipe: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pv *PartViewer) writeMailHeaders() {
|
||||||
|
info := pv.msg.MessageInfo()
|
||||||
|
if !config.Viewer.ShowHeaders || info.RFC822Headers == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pv.filter == pv.pager {
|
||||||
|
// Filter already implements its own paging.
|
||||||
|
// Piping another filter into it will cause mayhem.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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.ForAccount(account)
|
||||||
|
|
||||||
|
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)
|
||||||
|
switch {
|
||||||
|
case pv.filter == nil && canInline(pv.part.FullMIMEType()) && pv.err == nil:
|
||||||
|
pv.inlineImg = true
|
||||||
|
case pv.filter == nil:
|
||||||
|
// No filter, can't inline, and/or we attempted to inline an image
|
||||||
|
// and resulted in an error (maybe because of a bad encoding or
|
||||||
|
// the terminal doesn't support any graphics protocol).
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
|
||||||
|
pv.noFilter.Draw(ctx)
|
||||||
|
return
|
||||||
|
case !pv.fetched:
|
||||||
|
w, h := ctx.Window().Size()
|
||||||
|
pv.filter.Env = append(pv.filter.Env, fmt.Sprintf("COLUMNS=%d", w))
|
||||||
|
pv.filter.Env = append(pv.filter.Env, fmt.Sprintf("LINES=%d", h))
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if pv.image != nil && (pv.resized(ctx) || pv.graphic == nil) {
|
||||||
|
// This path should only occur on resizes or the first pass
|
||||||
|
// after the image is downloaded and could be slow due to
|
||||||
|
// encoding the image to either sixel or uploading via the kitty
|
||||||
|
// protocol. Generally it's pretty fast since we will only ever
|
||||||
|
// be downsizing images
|
||||||
|
vx := ctx.Window().Vx
|
||||||
|
if pv.graphic == nil {
|
||||||
|
var err error
|
||||||
|
pv.graphic, err = vx.NewImage(pv.image)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Couldn't create image: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pv.graphic.Resize(pv.width, pv.height)
|
||||||
|
}
|
||||||
|
if pv.graphic != nil {
|
||||||
|
w, h := pv.graphic.CellSize()
|
||||||
|
win := align.Center(ctx.Window(), w, h)
|
||||||
|
pv.graphic.Draw(win)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pv *PartViewer) Cleanup() {
|
||||||
|
if pv.term != nil {
|
||||||
|
pv.term.Close()
|
||||||
|
}
|
||||||
|
if pv.graphic != nil {
|
||||||
|
pv.graphic.Destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pv *PartViewer) resized(ctx *ui.Context) bool {
|
||||||
|
w := ctx.Width()
|
||||||
|
h := ctx.Height()
|
||||||
|
if pv.width != w || pv.height != h {
|
||||||
|
pv.width = w
|
||||||
|
pv.height = h
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pv *PartViewer) Event(event vaxis.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()
|
||||||
|
}
|
||||||
|
|
||||||
|
func canInline(mime string) bool {
|
||||||
|
for _, ext := range supportedImageTypes {
|
||||||
|
if mime == ext {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PartSwitcher struct {
|
||||||
|
Scrollable
|
||||||
|
parts []*PartViewer
|
||||||
|
selected int
|
||||||
|
|
||||||
|
height int
|
||||||
|
offset int
|
||||||
|
|
||||||
|
uiConfig *config.UIConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *PartSwitcher) PreviousPart() {
|
||||||
|
for {
|
||||||
|
ps.selected--
|
||||||
|
if ps.selected < 0 {
|
||||||
|
ps.selected = len(ps.parts) - 1
|
||||||
|
}
|
||||||
|
if ps.parts[ps.selected].part.MIMEType != "multipart" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *PartSwitcher) NextPart() {
|
||||||
|
for {
|
||||||
|
ps.selected++
|
||||||
|
if ps.selected >= len(ps.parts) {
|
||||||
|
ps.selected = 0
|
||||||
|
}
|
||||||
|
if ps.parts[ps.selected].part.MIMEType != "multipart" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *PartSwitcher) SelectedPart() *PartViewer {
|
||||||
|
return ps.parts[ps.selected]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *PartSwitcher) AttachmentParts(all bool) []*PartInfo {
|
||||||
|
var attachments []*PartInfo
|
||||||
|
for _, p := range ps.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 (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 vaxis.Event) bool {
|
||||||
|
return ps.parts[ps.selected].Event(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *PartSwitcher) Draw(ctx *ui.Context) {
|
||||||
|
uiConfig := ps.uiConfig
|
||||||
|
n := len(ps.parts)
|
||||||
|
if n == 1 && !config.Viewer.AlwaysShowMime {
|
||||||
|
ps.parts[ps.selected].Draw(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ps.height = config.Viewer.MaxMimeHeight
|
||||||
|
if ps.height <= 0 || n < ps.height {
|
||||||
|
ps.height = n
|
||||||
|
}
|
||||||
|
if ps.height > ctx.Height()/2 {
|
||||||
|
ps.height = ctx.Height() / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
ps.UpdateScroller(ps.height, n)
|
||||||
|
ps.EnsureScroll(ps.selected)
|
||||||
|
|
||||||
|
var styleSwitcher, styleFile, styleMime vaxis.Style
|
||||||
|
|
||||||
|
scrollbarWidth := 0
|
||||||
|
if ps.NeedScrollbar() {
|
||||||
|
scrollbarWidth = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ps.offset = ctx.Height() - ps.height
|
||||||
|
y := ps.offset
|
||||||
|
row := ps.offset
|
||||||
|
ctx.Fill(0, y, ctx.Width(), ps.height, ' ', uiConfig.GetStyle(config.STYLE_PART_SWITCHER))
|
||||||
|
for i := ps.Scroll(); i < n; i++ {
|
||||||
|
part := ps.parts[i]
|
||||||
|
if ps.selected == i {
|
||||||
|
styleSwitcher = uiConfig.GetStyleSelected(config.STYLE_PART_SWITCHER)
|
||||||
|
styleFile = uiConfig.GetStyleSelected(config.STYLE_PART_FILENAME)
|
||||||
|
styleMime = uiConfig.GetStyleSelected(config.STYLE_PART_MIMETYPE)
|
||||||
|
} else {
|
||||||
|
styleSwitcher = uiConfig.GetStyle(config.STYLE_PART_SWITCHER)
|
||||||
|
styleFile = uiConfig.GetStyle(config.STYLE_PART_FILENAME)
|
||||||
|
styleMime = uiConfig.GetStyle(config.STYLE_PART_MIMETYPE)
|
||||||
|
}
|
||||||
|
ctx.Fill(0, row, ctx.Width(), 1, ' ', styleSwitcher)
|
||||||
|
left := len(part.index) * 2
|
||||||
|
if part.part.FileName() != "" {
|
||||||
|
name := runewidth.Truncate(part.part.FileName(),
|
||||||
|
ctx.Width()-left-1, "…")
|
||||||
|
left += ctx.Printf(left, row, styleFile, "%s ", name)
|
||||||
|
}
|
||||||
|
t := "(" + part.part.FullMIMEType() + ")"
|
||||||
|
t = runewidth.Truncate(t, ctx.Width()-left-scrollbarWidth, "…")
|
||||||
|
ctx.Printf(left, row, styleMime, "%s", t)
|
||||||
|
row++
|
||||||
|
|
||||||
|
if (i - ps.Scroll()) >= ps.height {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ps.NeedScrollbar() {
|
||||||
|
ps.drawScrollbar(ctx.Subcontext(ctx.Width()-1, y, 1, ps.height))
|
||||||
|
}
|
||||||
|
ps.parts[ps.selected].Draw(ctx.Subcontext(
|
||||||
|
0, 0, ctx.Width(), ctx.Height()-ps.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *PartSwitcher) drawScrollbar(ctx *ui.Context) {
|
||||||
|
uiConfig := ps.uiConfig
|
||||||
|
gutterStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_GUTTER)
|
||||||
|
pillStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_PILL)
|
||||||
|
|
||||||
|
// gutter
|
||||||
|
ctx.Fill(0, 0, 1, ctx.Height(), ' ', gutterStyle)
|
||||||
|
|
||||||
|
// pill
|
||||||
|
pillSize := int(math.Ceil(float64(ctx.Height()) * ps.PercentVisible()))
|
||||||
|
pillOffset := int(math.Floor(float64(ctx.Height()) * ps.PercentScrolled()))
|
||||||
|
ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *PartSwitcher) MouseEvent(localX int, localY int, event vaxis.Event) {
|
||||||
|
if localY < ps.offset && ps.parts[ps.selected].term != nil {
|
||||||
|
ps.parts[ps.selected].term.MouseEvent(localX, localY, event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e, ok := event.(vaxis.Mouse)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.parts[ps.selected].term != nil {
|
||||||
|
ps.parts[ps.selected].term.Focus(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch e.Button {
|
||||||
|
case vaxis.MouseLeftButton:
|
||||||
|
i := localY - ps.offset + ps.Scroll()
|
||||||
|
if i < 0 || i >= len(ps.parts) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ps.parts[i].part.MIMEType == "multipart" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
ps.selected = i
|
||||||
|
ps.Invalidate()
|
||||||
|
case vaxis.MouseWheelDown:
|
||||||
|
ps.NextPart()
|
||||||
|
ps.Invalidate()
|
||||||
|
case vaxis.MouseWheelUp:
|
||||||
|
ps.PreviousPart()
|
||||||
|
ps.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PGPInfo struct {
|
||||||
|
details *models.MessageDetails
|
||||||
|
uiConfig *config.UIConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPGPInfo(details *models.MessageDetails, uiConfig *config.UIConfig) *PGPInfo {
|
||||||
|
return &PGPInfo{details: details, uiConfig: uiConfig}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PGPInfo) DrawSignature(ctx *ui.Context) {
|
||||||
|
errorStyle := p.uiConfig.GetStyle(config.STYLE_ERROR)
|
||||||
|
warningStyle := p.uiConfig.GetStyle(config.STYLE_WARNING)
|
||||||
|
validStyle := p.uiConfig.GetStyle(config.STYLE_SUCCESS)
|
||||||
|
defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT)
|
||||||
|
|
||||||
|
var icon string
|
||||||
|
var indicatorStyle, textstyle vaxis.Style
|
||||||
|
textstyle = defaultStyle
|
||||||
|
var indicatorText, messageText string
|
||||||
|
// TODO: Nicer prompt for TOFU, fetch from keyserver, etc
|
||||||
|
switch p.details.SignatureValidity {
|
||||||
|
case models.UnknownEntity:
|
||||||
|
icon = p.uiConfig.IconUnknown
|
||||||
|
indicatorStyle = warningStyle
|
||||||
|
indicatorText = "Unknown"
|
||||||
|
messageText = fmt.Sprintf("Signed with unknown key (%8X); authenticity unknown", p.details.SignedByKeyId)
|
||||||
|
case models.Valid:
|
||||||
|
icon = p.uiConfig.IconSigned
|
||||||
|
if p.details.IsEncrypted && p.uiConfig.IconSignedEncrypted != "" {
|
||||||
|
icon = p.uiConfig.IconSignedEncrypted
|
||||||
|
}
|
||||||
|
indicatorStyle = validStyle
|
||||||
|
indicatorText = "Authentic"
|
||||||
|
messageText = fmt.Sprintf("Signature from %s (%8X)", p.details.SignedBy, p.details.SignedByKeyId)
|
||||||
|
default:
|
||||||
|
icon = p.uiConfig.IconInvalid
|
||||||
|
indicatorStyle = errorStyle
|
||||||
|
indicatorText = "Invalid signature!"
|
||||||
|
messageText = fmt.Sprintf("This message may have been tampered with! (%s)", p.details.SignatureError)
|
||||||
|
}
|
||||||
|
|
||||||
|
x := ctx.Printf(0, 0, indicatorStyle, "%s %s ", icon, indicatorText)
|
||||||
|
ctx.Printf(x, 0, textstyle, "%s", messageText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PGPInfo) DrawEncryption(ctx *ui.Context, y int) {
|
||||||
|
warningStyle := p.uiConfig.GetStyle(config.STYLE_WARNING)
|
||||||
|
validStyle := p.uiConfig.GetStyle(config.STYLE_SUCCESS)
|
||||||
|
defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT)
|
||||||
|
|
||||||
|
// if a sign-encrypt combination icon is set, use that
|
||||||
|
icon := p.uiConfig.IconEncrypted
|
||||||
|
if p.details.IsSigned && p.details.SignatureValidity == models.Valid && p.uiConfig.IconSignedEncrypted != "" {
|
||||||
|
icon = strings.Repeat(" ", utf8.RuneCountInString(p.uiConfig.IconSignedEncrypted))
|
||||||
|
}
|
||||||
|
|
||||||
|
x := ctx.Printf(0, y, validStyle, "%s Encrypted", icon)
|
||||||
|
x += ctx.Printf(x+1, y, defaultStyle, "To %s (%8X) ", p.details.DecryptedWith, p.details.DecryptedWithKeyId)
|
||||||
|
if !p.details.IsSigned {
|
||||||
|
ctx.Printf(x, y, warningStyle, "(message not signed!)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PGPInfo) Draw(ctx *ui.Context) {
|
||||||
|
warningStyle := p.uiConfig.GetStyle(config.STYLE_WARNING)
|
||||||
|
defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT)
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case p.details == nil && p.uiConfig.IconUnencrypted != "":
|
||||||
|
x := ctx.Printf(0, 0, warningStyle, "%s ", p.uiConfig.IconUnencrypted)
|
||||||
|
ctx.Printf(x, 0, defaultStyle, "message unencrypted and unsigned")
|
||||||
|
case p.details.IsSigned && p.details.IsEncrypted:
|
||||||
|
p.DrawSignature(ctx)
|
||||||
|
p.DrawEncryption(ctx, 1)
|
||||||
|
case p.details.IsSigned:
|
||||||
|
p.DrawSignature(ctx)
|
||||||
|
case p.details.IsEncrypted:
|
||||||
|
p.DrawEncryption(ctx, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PGPInfo) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PartInfo struct {
|
||||||
|
Index []int
|
||||||
|
Msg *models.MessageInfo
|
||||||
|
Part *models.BodyStructure
|
||||||
|
Links []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProvidesMessage interface {
|
||||||
|
ui.Drawable
|
||||||
|
Store() *lib.MessageStore
|
||||||
|
SelectedAccount() *AccountView
|
||||||
|
SelectedMessage() (*models.MessageInfo, error)
|
||||||
|
SelectedMessagePart() *PartInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProvidesMessages interface {
|
||||||
|
ui.Drawable
|
||||||
|
Store() *lib.MessageStore
|
||||||
|
SelectedAccount() *AccountView
|
||||||
|
SelectedMessage() (*models.MessageInfo, error)
|
||||||
|
MarkedMessages() ([]models.UID, error)
|
||||||
|
}
|
||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
"github.com/riywo/loginshell"
|
||||||
|
)
|
||||||
|
|
||||||
|
var qt quakeTerminal
|
||||||
|
|
||||||
|
type quakeTerminal struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rolling int32
|
||||||
|
visible bool
|
||||||
|
term *Terminal
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToggleQuake() {
|
||||||
|
handleErr := func(err error) {
|
||||||
|
log.Errorf("quake-terminal: %v", err)
|
||||||
|
}
|
||||||
|
if !qt.HasTerm() {
|
||||||
|
shell, err := loginshell.Shell()
|
||||||
|
if err != nil {
|
||||||
|
handleErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args := []string{shell}
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
term, err := NewTerminal(cmd)
|
||||||
|
if err != nil {
|
||||||
|
handleErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
term.OnClose = func(err error) {
|
||||||
|
if err != nil {
|
||||||
|
aerc.PushError(err.Error())
|
||||||
|
}
|
||||||
|
qt.Hide()
|
||||||
|
qt.SetTerm(nil)
|
||||||
|
}
|
||||||
|
qt.SetTerm(term)
|
||||||
|
}
|
||||||
|
|
||||||
|
if qt.Rolling() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if qt.Visible() {
|
||||||
|
qt.Hide()
|
||||||
|
} else {
|
||||||
|
qt.Show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *quakeTerminal) Rolling() bool {
|
||||||
|
return atomic.LoadInt32(&q.rolling) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *quakeTerminal) SetTerm(t *Terminal) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
q.term = t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *quakeTerminal) HasTerm() bool {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
return q.term != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *quakeTerminal) Visible() bool {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
return q.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputReturn is helper function to create dialog boxes.
|
||||||
|
func inputReturn() func(int) int {
|
||||||
|
return func(x int) int { return x }
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixReturn is helper function to create dialog boxes.
|
||||||
|
func fixReturn(x int) func(int) int {
|
||||||
|
return func(_ int) int { return x }
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *quakeTerminal) Show() {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
if q.term == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uiConfig := SelectedAccountUiConfig()
|
||||||
|
h := uiConfig.QuakeHeight
|
||||||
|
|
||||||
|
termBox := NewDialog(
|
||||||
|
ui.NewBox(q.term, "", "", uiConfig),
|
||||||
|
fixReturn(0),
|
||||||
|
fixReturn(0),
|
||||||
|
inputReturn(),
|
||||||
|
fixReturn(h),
|
||||||
|
)
|
||||||
|
|
||||||
|
f := Roller{
|
||||||
|
span: 100 * time.Millisecond,
|
||||||
|
done: func() {
|
||||||
|
log.Tracef("restore after show")
|
||||||
|
atomic.StoreInt32(&q.rolling, 0)
|
||||||
|
ui.QueueFunc(func() {
|
||||||
|
CloseDialog()
|
||||||
|
AddDialog(termBox)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.StoreInt32(&q.rolling, 1)
|
||||||
|
emptyBox := NewDialog(
|
||||||
|
ui.NewBox(&EmptyInteractive{}, "", "", uiConfig),
|
||||||
|
fixReturn(0),
|
||||||
|
fixReturn(0),
|
||||||
|
inputReturn(),
|
||||||
|
f.Roll(1, h),
|
||||||
|
)
|
||||||
|
|
||||||
|
q.visible = true
|
||||||
|
if q.term != nil {
|
||||||
|
q.term.Show(q.visible)
|
||||||
|
q.term.Focus(q.visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseDialog()
|
||||||
|
AddDialog(emptyBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *quakeTerminal) Hide() {
|
||||||
|
uiConfig := SelectedAccountUiConfig()
|
||||||
|
f := Roller{
|
||||||
|
span: 100 * time.Millisecond,
|
||||||
|
done: func() {
|
||||||
|
atomic.StoreInt32(&q.rolling, 0)
|
||||||
|
ui.QueueFunc(CloseDialog)
|
||||||
|
log.Tracef("restore after hide")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.StoreInt32(&q.rolling, 1)
|
||||||
|
emptyBox := NewDialog(
|
||||||
|
ui.NewBox(&EmptyInteractive{}, "", "", uiConfig),
|
||||||
|
fixReturn(0),
|
||||||
|
fixReturn(0),
|
||||||
|
inputReturn(),
|
||||||
|
f.Roll(uiConfig.QuakeHeight, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
q.mu.Lock()
|
||||||
|
q.visible = false
|
||||||
|
if q.term != nil {
|
||||||
|
q.term.Focus(q.visible)
|
||||||
|
q.term.Show(q.visible)
|
||||||
|
}
|
||||||
|
q.mu.Unlock()
|
||||||
|
|
||||||
|
ui.QueueFunc(func() {
|
||||||
|
CloseDialog()
|
||||||
|
AddDialog(emptyBox)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmptyInteractive struct{}
|
||||||
|
|
||||||
|
func (e *EmptyInteractive) Draw(ctx *ui.Context) {
|
||||||
|
w := ctx.Width()
|
||||||
|
h := ctx.Height()
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
style := SelectedAccountUiConfig().GetStyle(config.STYLE_DEFAULT)
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmptyInteractive) Invalidate() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmptyInteractive) MouseEvent(_ int, _ int, _ vaxis.Event) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmptyInteractive) Event(_ vaxis.Event) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmptyInteractive) Focus(_ bool) {
|
||||||
|
}
|
||||||
|
|
||||||
|
type Roller struct {
|
||||||
|
span time.Duration
|
||||||
|
done func()
|
||||||
|
value int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Roller) Roll(start, end int) func(int) int {
|
||||||
|
nsteps := end - start
|
||||||
|
|
||||||
|
var step int64 = 1
|
||||||
|
if end < start {
|
||||||
|
step = -1
|
||||||
|
nsteps = -nsteps
|
||||||
|
}
|
||||||
|
|
||||||
|
span := f.span.Milliseconds() / int64(nsteps)
|
||||||
|
refresh := time.Duration(span) * time.Millisecond
|
||||||
|
|
||||||
|
atomic.StoreInt64(&f.value, int64(start))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
for i := 0; i < int(nsteps); i++ {
|
||||||
|
aerc.Invalidate()
|
||||||
|
time.Sleep(refresh)
|
||||||
|
atomic.AddInt64(&f.value, step)
|
||||||
|
}
|
||||||
|
if f.done != nil {
|
||||||
|
ui.QueueFunc(f.done)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return func(_ int) int {
|
||||||
|
log.Tracef("in roller")
|
||||||
|
return int(atomic.LoadInt64(&f.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
// Scrollable implements vertical scrolling
|
||||||
|
type Scrollable struct {
|
||||||
|
scroll int
|
||||||
|
offset int
|
||||||
|
height int
|
||||||
|
elems int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scrollable) Scroll() int {
|
||||||
|
return s.scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scrollable) SetOffset(offset int) {
|
||||||
|
s.offset = offset
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scrollable) ScrollOffset() int {
|
||||||
|
return s.offset
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scrollable) PercentVisible() float64 {
|
||||||
|
if s.elems <= 0 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
return float64(s.height) / float64(s.elems)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scrollable) PercentScrolled() float64 {
|
||||||
|
if s.elems <= 0 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
return float64(s.scroll) / float64(s.elems)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scrollable) NeedScrollbar() bool {
|
||||||
|
needScrollbar := true
|
||||||
|
if s.PercentVisible() >= 1.0 {
|
||||||
|
needScrollbar = false
|
||||||
|
}
|
||||||
|
return needScrollbar
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scrollable) UpdateScroller(height, elems int) {
|
||||||
|
s.height = height
|
||||||
|
s.elems = elems
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scrollable) EnsureScroll(idx int) {
|
||||||
|
if idx < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
middle := s.height / 2
|
||||||
|
switch {
|
||||||
|
case s.offset > middle:
|
||||||
|
s.scroll = idx - middle
|
||||||
|
case idx < s.scroll+s.offset:
|
||||||
|
s.scroll = idx - s.offset
|
||||||
|
case idx >= s.scroll-s.offset+s.height:
|
||||||
|
s.scroll = idx + s.offset - s.height + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
s.checkBounds()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scrollable) checkBounds() {
|
||||||
|
maxScroll := s.elems - s.height
|
||||||
|
if maxScroll < 0 {
|
||||||
|
maxScroll = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.scroll > maxScroll {
|
||||||
|
s.scroll = maxScroll
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.scroll < 0 {
|
||||||
|
s.scroll = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlignPosition uint
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlignTop AlignPosition = iota
|
||||||
|
AlignCenter
|
||||||
|
AlignBottom
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Scrollable) Align(idx int, pos AlignPosition) {
|
||||||
|
switch pos {
|
||||||
|
case AlignTop:
|
||||||
|
s.scroll = idx
|
||||||
|
case AlignCenter:
|
||||||
|
s.scroll = idx - s.height/2
|
||||||
|
case AlignBottom:
|
||||||
|
s.scroll = idx - s.height + 1
|
||||||
|
}
|
||||||
|
s.checkBounds()
|
||||||
|
}
|
||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Selector struct {
|
||||||
|
chooser bool
|
||||||
|
focused bool
|
||||||
|
focus int
|
||||||
|
options []string
|
||||||
|
uiConfig *config.UIConfig
|
||||||
|
|
||||||
|
onChoose func(option string)
|
||||||
|
onSelect func(option string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSelector(options []string, focus int, uiConfig *config.UIConfig) *Selector {
|
||||||
|
return &Selector{
|
||||||
|
focus: focus,
|
||||||
|
options: options,
|
||||||
|
uiConfig: uiConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sel *Selector) Chooser(chooser bool) *Selector {
|
||||||
|
sel.chooser = chooser
|
||||||
|
return sel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sel *Selector) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sel *Selector) Draw(ctx *ui.Context) {
|
||||||
|
defaultSelectorStyle := sel.uiConfig.GetStyle(config.STYLE_SELECTOR_DEFAULT)
|
||||||
|
w, h := ctx.Width(), ctx.Height()
|
||||||
|
ctx.Fill(0, 0, w, h, ' ', defaultSelectorStyle)
|
||||||
|
|
||||||
|
if w < 5 || h < 1 {
|
||||||
|
// if width and height are that small, don't even try to draw
|
||||||
|
// something
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
y := 1
|
||||||
|
if h == 1 {
|
||||||
|
y = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
format := "[%s]"
|
||||||
|
|
||||||
|
calculateWidth := func(space int) int {
|
||||||
|
neededWidth := 2
|
||||||
|
for i, option := range sel.options {
|
||||||
|
neededWidth += runewidth.StringWidth(fmt.Sprintf(format, option))
|
||||||
|
if i < len(sel.options)-1 {
|
||||||
|
neededWidth += space
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return neededWidth - space
|
||||||
|
}
|
||||||
|
|
||||||
|
space := 5
|
||||||
|
for ; space > 0; space-- {
|
||||||
|
if w > calculateWidth(space) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
x := 2
|
||||||
|
for i, option := range sel.options {
|
||||||
|
style := defaultSelectorStyle
|
||||||
|
if sel.focus == i {
|
||||||
|
if sel.focused {
|
||||||
|
style = sel.uiConfig.GetStyle(config.STYLE_SELECTOR_FOCUSED)
|
||||||
|
} else if sel.chooser {
|
||||||
|
style = sel.uiConfig.GetStyle(config.STYLE_SELECTOR_CHOOSER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if space == 0 {
|
||||||
|
if sel.focus == i {
|
||||||
|
leftArrow, rightArrow := ' ', ' '
|
||||||
|
if i > 0 {
|
||||||
|
leftArrow = '❮'
|
||||||
|
}
|
||||||
|
if i < len(sel.options)-1 {
|
||||||
|
rightArrow = '❯'
|
||||||
|
}
|
||||||
|
|
||||||
|
s := runewidth.Truncate(option,
|
||||||
|
w-runewidth.RuneWidth(leftArrow)-runewidth.RuneWidth(rightArrow)-runewidth.StringWidth(fmt.Sprintf(format, "")),
|
||||||
|
"…")
|
||||||
|
|
||||||
|
nextPos := 0
|
||||||
|
nextPos += ctx.Printf(nextPos, y, defaultSelectorStyle, "%c", leftArrow)
|
||||||
|
nextPos += ctx.Printf(nextPos, y, style, format, s)
|
||||||
|
ctx.Printf(nextPos, y, defaultSelectorStyle, "%c", rightArrow)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
x += ctx.Printf(x, y, style, format, option)
|
||||||
|
x += space
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sel *Selector) OnChoose(fn func(option string)) *Selector {
|
||||||
|
sel.onChoose = fn
|
||||||
|
return sel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sel *Selector) OnSelect(fn func(option string)) *Selector {
|
||||||
|
sel.onSelect = fn
|
||||||
|
return sel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sel *Selector) Select(option string) {
|
||||||
|
for i, opt := range sel.options {
|
||||||
|
if option == opt {
|
||||||
|
sel.focus = i
|
||||||
|
if sel.onSelect != nil {
|
||||||
|
sel.onSelect(opt)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sel *Selector) Selected() string {
|
||||||
|
return sel.options[sel.focus]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sel *Selector) Focus(focus bool) {
|
||||||
|
sel.focused = focus
|
||||||
|
sel.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sel *Selector) Event(event vaxis.Event) bool {
|
||||||
|
if key, ok := event.(vaxis.Key); ok {
|
||||||
|
switch {
|
||||||
|
case key.Matches('h', vaxis.ModCtrl):
|
||||||
|
fallthrough
|
||||||
|
case key.Matches(vaxis.KeyLeft):
|
||||||
|
if sel.focus > 0 {
|
||||||
|
sel.focus--
|
||||||
|
sel.Invalidate()
|
||||||
|
}
|
||||||
|
if sel.onSelect != nil {
|
||||||
|
sel.onSelect(sel.Selected())
|
||||||
|
}
|
||||||
|
case key.Matches('l', vaxis.ModCtrl):
|
||||||
|
fallthrough
|
||||||
|
case key.Matches(vaxis.KeyRight):
|
||||||
|
if sel.focus < len(sel.options)-1 {
|
||||||
|
sel.focus++
|
||||||
|
sel.Invalidate()
|
||||||
|
}
|
||||||
|
if sel.onSelect != nil {
|
||||||
|
sel.onSelect(sel.Selected())
|
||||||
|
}
|
||||||
|
case key.Matches(vaxis.KeyEnter):
|
||||||
|
if sel.onChoose != nil {
|
||||||
|
sel.onChoose(sel.Selected())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNoOptionSelected = fmt.Errorf("no option selected")
|
||||||
|
|
||||||
|
type SelectorDialog struct {
|
||||||
|
callback func(string, error)
|
||||||
|
title string
|
||||||
|
prompt string
|
||||||
|
uiConfig *config.UIConfig
|
||||||
|
selector *Selector
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSelectorDialog(title string, prompt string, options []string, focus int,
|
||||||
|
uiConfig *config.UIConfig, cb func(string, error),
|
||||||
|
) *SelectorDialog {
|
||||||
|
sd := &SelectorDialog{
|
||||||
|
callback: cb,
|
||||||
|
title: title,
|
||||||
|
prompt: strings.TrimSpace(prompt),
|
||||||
|
uiConfig: uiConfig,
|
||||||
|
selector: NewSelector(options, focus, uiConfig).Chooser(true),
|
||||||
|
}
|
||||||
|
sd.selector.Focus(true)
|
||||||
|
return sd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gp *SelectorDialog) Draw(ctx *ui.Context) {
|
||||||
|
defaultStyle := gp.uiConfig.GetStyle(config.STYLE_DEFAULT)
|
||||||
|
titleStyle := gp.uiConfig.GetStyle(config.STYLE_TITLE)
|
||||||
|
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle)
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), 1, ' ', titleStyle)
|
||||||
|
ctx.Printf(1, 0, titleStyle, "%s", gp.title)
|
||||||
|
var i int
|
||||||
|
lines := strings.Split(gp.prompt, "\n")
|
||||||
|
for i = 0; i < len(lines); i++ {
|
||||||
|
ctx.Printf(1, 2+i, defaultStyle, "%s", lines[i])
|
||||||
|
}
|
||||||
|
gp.selector.Draw(ctx.Subcontext(1, ctx.Height()-1, ctx.Width()-2, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gp *SelectorDialog) ContextWidth() (func(int) int, func(int) int) {
|
||||||
|
// horizontal starting position in columns from the left
|
||||||
|
start := func(int) int {
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
// dialog width from the starting column
|
||||||
|
width := func(w int) int {
|
||||||
|
return w - 8
|
||||||
|
}
|
||||||
|
return start, width
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gp *SelectorDialog) ContextHeight() (func(int) int, func(int) int) {
|
||||||
|
totalHeight := 2 // title + empty line
|
||||||
|
totalHeight += strings.Count(gp.prompt, "\n") + 1
|
||||||
|
totalHeight += 2 // empty line + selector
|
||||||
|
start := func(h int) int {
|
||||||
|
s := h/2 - totalHeight/2
|
||||||
|
if s < 0 {
|
||||||
|
s = 0
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
height := func(h int) int {
|
||||||
|
if totalHeight > h {
|
||||||
|
return h
|
||||||
|
} else {
|
||||||
|
return totalHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return start, height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gp *SelectorDialog) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gp *SelectorDialog) Event(event vaxis.Event) bool {
|
||||||
|
switch event := event.(type) {
|
||||||
|
case vaxis.Key:
|
||||||
|
switch {
|
||||||
|
case event.Matches(vaxis.KeyEnter):
|
||||||
|
gp.selector.Focus(false)
|
||||||
|
gp.callback(gp.selector.Selected(), nil)
|
||||||
|
case event.Matches(vaxis.KeyEsc):
|
||||||
|
gp.selector.Focus(false)
|
||||||
|
gp.callback("", ErrNoOptionSelected)
|
||||||
|
default:
|
||||||
|
gp.selector.Event(event)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
gp.selector.Event(event)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gp *SelectorDialog) Focus(f bool) {
|
||||||
|
gp.selector.Focus(f)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Spinner struct {
|
||||||
|
frame int64 // access via atomic
|
||||||
|
frames []string
|
||||||
|
interval time.Duration
|
||||||
|
stop chan struct{}
|
||||||
|
style vaxis.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSpinner(uiConf *config.UIConfig) *Spinner {
|
||||||
|
spinner := Spinner{
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
frame: -1,
|
||||||
|
interval: uiConf.SpinnerInterval,
|
||||||
|
frames: strings.Split(uiConf.Spinner, uiConf.SpinnerDelimiter),
|
||||||
|
style: uiConf.GetStyle(config.STYLE_SPINNER),
|
||||||
|
}
|
||||||
|
return &spinner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Spinner) Start() {
|
||||||
|
if s.IsRunning() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.StoreInt64(&s.frame, 0)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
atomic.StoreInt64(&s.frame, -1)
|
||||||
|
s.stop <- struct{}{}
|
||||||
|
return
|
||||||
|
case <-time.After(s.interval):
|
||||||
|
atomic.AddInt64(&s.frame, 1)
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Spinner) Stop() {
|
||||||
|
if !s.IsRunning() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.stop <- struct{}{}
|
||||||
|
<-s.stop
|
||||||
|
s.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Spinner) IsRunning() bool {
|
||||||
|
return atomic.LoadInt64(&s.frame) != -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Spinner) Draw(ctx *ui.Context) {
|
||||||
|
if !s.IsRunning() {
|
||||||
|
s.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
cur := int(atomic.LoadInt64(&s.frame) % int64(len(s.frames)))
|
||||||
|
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', s.style)
|
||||||
|
col := ctx.Width()/2 - len(s.frames[0])/2 + 1
|
||||||
|
ctx.Printf(col, 0, s.style, "%s", s.frames[cur])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Spinner) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/templates"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusLine struct {
|
||||||
|
sync.Mutex
|
||||||
|
stack []*StatusMessage
|
||||||
|
acct *AccountView
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusMessage struct {
|
||||||
|
style vaxis.Style
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status *StatusLine) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status *StatusLine) Draw(ctx *ui.Context) {
|
||||||
|
status.Lock()
|
||||||
|
defer status.Unlock()
|
||||||
|
style := status.uiConfig().GetStyle(config.STYLE_STATUSLINE_DEFAULT)
|
||||||
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
|
||||||
|
switch {
|
||||||
|
case len(status.stack) != 0:
|
||||||
|
line := status.stack[len(status.stack)-1]
|
||||||
|
msg := runewidth.Truncate(line.message, ctx.Width(), "")
|
||||||
|
msg = runewidth.FillRight(msg, ctx.Width())
|
||||||
|
ctx.Printf(0, 0, line.style, "%s", msg)
|
||||||
|
case status.err != "":
|
||||||
|
msg := runewidth.Truncate(status.err, ctx.Width(), "")
|
||||||
|
msg = runewidth.FillRight(msg, ctx.Width())
|
||||||
|
style := status.uiConfig().GetStyle(config.STYLE_STATUSLINE_ERROR)
|
||||||
|
ctx.Printf(0, 0, style, "%s", msg)
|
||||||
|
case status.acct != nil:
|
||||||
|
data := state.NewDataSetter()
|
||||||
|
data.SetPendingKeys(aerc.pendingKeys)
|
||||||
|
data.SetState(&status.acct.state)
|
||||||
|
data.SetAccount(status.acct.acct)
|
||||||
|
data.SetFolder(status.acct.Directories().SelectedDirectory())
|
||||||
|
msg, _ := status.acct.SelectedMessage()
|
||||||
|
data.SetInfo(msg, 0, false)
|
||||||
|
data.SetRUE(status.acct.dirlist.List(), status.acct.dirlist.GetRUECount)
|
||||||
|
if store := status.acct.Store(); store != nil {
|
||||||
|
data.SetVisual(store.Marker().IsVisualMark())
|
||||||
|
}
|
||||||
|
table := ui.NewTable(
|
||||||
|
ctx.Height(),
|
||||||
|
config.Statusline.StatusColumns,
|
||||||
|
config.Statusline.ColumnSeparator,
|
||||||
|
nil,
|
||||||
|
func(*ui.Table, int) vaxis.Style { return style },
|
||||||
|
)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
cells := make([]string, len(table.Columns))
|
||||||
|
for c, col := range table.Columns {
|
||||||
|
err := templates.Render(col.Def.Template, &buf,
|
||||||
|
data.Data())
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%s", err)
|
||||||
|
cells[c] = err.Error()
|
||||||
|
} else {
|
||||||
|
cells[c] = buf.String()
|
||||||
|
}
|
||||||
|
buf.Reset()
|
||||||
|
}
|
||||||
|
table.AddRow(cells, nil)
|
||||||
|
table.Draw(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status *StatusLine) Update(acct *AccountView) {
|
||||||
|
status.acct = acct
|
||||||
|
status.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status *StatusLine) SetError(err string) {
|
||||||
|
prev := status.err
|
||||||
|
status.err = err
|
||||||
|
if prev != status.err {
|
||||||
|
status.Invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status *StatusLine) Clear() {
|
||||||
|
status.SetError("")
|
||||||
|
status.acct = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status *StatusLine) Push(text string, expiry time.Duration) *StatusMessage {
|
||||||
|
status.Lock()
|
||||||
|
defer status.Unlock()
|
||||||
|
log.Debugf(text)
|
||||||
|
msg := &StatusMessage{
|
||||||
|
style: status.uiConfig().GetStyle(config.STYLE_STATUSLINE_DEFAULT),
|
||||||
|
message: text,
|
||||||
|
}
|
||||||
|
status.stack = append(status.stack, msg)
|
||||||
|
go (func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
|
||||||
|
time.Sleep(expiry)
|
||||||
|
status.Lock()
|
||||||
|
defer status.Unlock()
|
||||||
|
for i, m := range status.stack {
|
||||||
|
if m == msg {
|
||||||
|
status.stack = append(status.stack[:i], status.stack[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status.Invalidate()
|
||||||
|
})()
|
||||||
|
status.Invalidate()
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status *StatusLine) PushError(text string) *StatusMessage {
|
||||||
|
log.Errorf(text)
|
||||||
|
msg := status.Push(text, 10*time.Second)
|
||||||
|
msg.Color(status.uiConfig().GetStyle(config.STYLE_STATUSLINE_ERROR))
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status *StatusLine) PushWarning(text string) *StatusMessage {
|
||||||
|
log.Warnf(text)
|
||||||
|
msg := status.Push(text, 10*time.Second)
|
||||||
|
msg.Color(status.uiConfig().GetStyle(config.STYLE_STATUSLINE_WARNING))
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status *StatusLine) PushSuccess(text string) *StatusMessage {
|
||||||
|
log.Tracef(text)
|
||||||
|
msg := status.Push(text, 10*time.Second)
|
||||||
|
msg.Color(status.uiConfig().GetStyle(config.STYLE_STATUSLINE_SUCCESS))
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status *StatusLine) Expire() {
|
||||||
|
status.Lock()
|
||||||
|
defer status.Unlock()
|
||||||
|
status.stack = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status *StatusLine) uiConfig() *config.UIConfig {
|
||||||
|
return SelectedAccountUiConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *StatusMessage) Color(style vaxis.Style) {
|
||||||
|
msg.style = style
|
||||||
|
}
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rockorager/vaxis"
|
||||||
|
"git.sr.ht/~rockorager/vaxis/widgets/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HasTerminal interface {
|
||||||
|
Terminal() *Terminal
|
||||||
|
}
|
||||||
|
|
||||||
|
type Terminal struct {
|
||||||
|
closed int32
|
||||||
|
visible int32 // visible if >0
|
||||||
|
cmd *exec.Cmd
|
||||||
|
ctx *ui.Context
|
||||||
|
focus bool
|
||||||
|
vterm *term.Model
|
||||||
|
running bool
|
||||||
|
|
||||||
|
OnClose func(err error)
|
||||||
|
OnEvent func(event vaxis.Event) bool
|
||||||
|
OnStart func()
|
||||||
|
OnTitle func(title string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTerminal(cmd *exec.Cmd) (*Terminal, error) {
|
||||||
|
term := &Terminal{
|
||||||
|
cmd: cmd,
|
||||||
|
vterm: term.New(),
|
||||||
|
visible: 1,
|
||||||
|
}
|
||||||
|
term.vterm.OSC8 = config.General.EnableOSC8
|
||||||
|
term.vterm.TERM = config.General.Term
|
||||||
|
return term, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (term *Terminal) Close() {
|
||||||
|
term.closeErr(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: replace with atomic.Bool when min go version will have it (1.19+)
|
||||||
|
const closed int32 = 1
|
||||||
|
|
||||||
|
func (term *Terminal) isClosed() bool {
|
||||||
|
return atomic.LoadInt32(&term.closed) == closed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (term *Terminal) closeErr(err error) {
|
||||||
|
if atomic.SwapInt32(&term.closed, closed) == closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if term.vterm != nil {
|
||||||
|
// Stop receiving events
|
||||||
|
term.vterm.Detach()
|
||||||
|
term.vterm.Close()
|
||||||
|
if term.ctx != nil {
|
||||||
|
term.ctx.HideCursor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if term.OnClose != nil {
|
||||||
|
term.OnClose(err)
|
||||||
|
}
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (term *Terminal) Destroy() {
|
||||||
|
// If we destroy, we don't want to call the OnClose callback
|
||||||
|
term.OnClose = nil
|
||||||
|
term.closeErr(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (term *Terminal) Invalidate() {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (term *Terminal) Draw(ctx *ui.Context) {
|
||||||
|
if ctx.Width() == 0 || ctx.Height() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
term.ctx = ctx
|
||||||
|
if !term.running && term.cmd != nil {
|
||||||
|
term.vterm.Attach(term.HandleEvent)
|
||||||
|
w, h := ctx.Window().Size()
|
||||||
|
if err := term.vterm.StartWithSize(term.cmd, w, h); err != nil {
|
||||||
|
log.Errorf("error running terminal: %v", err)
|
||||||
|
term.closeErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
term.running = true
|
||||||
|
if term.OnStart != nil {
|
||||||
|
term.OnStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
term.vterm.Draw(ctx.Window())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (term *Terminal) Show(visible bool) {
|
||||||
|
if visible {
|
||||||
|
atomic.StoreInt32(&term.visible, 1)
|
||||||
|
} else {
|
||||||
|
atomic.StoreInt32(&term.visible, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (term *Terminal) Terminal() *Terminal {
|
||||||
|
return term
|
||||||
|
}
|
||||||
|
|
||||||
|
func (term *Terminal) MouseEvent(localX int, localY int, event vaxis.Event) {
|
||||||
|
ev, ok := event.(vaxis.Mouse)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if term.OnEvent != nil {
|
||||||
|
term.OnEvent(ev)
|
||||||
|
}
|
||||||
|
if term.isClosed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ev.Row = localY
|
||||||
|
ev.Col = localX
|
||||||
|
term.vterm.Update(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (term *Terminal) Focus(focus bool) {
|
||||||
|
if term.isClosed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
term.focus = focus
|
||||||
|
if term.focus {
|
||||||
|
term.vterm.Focus()
|
||||||
|
} else {
|
||||||
|
term.vterm.Blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEvent is used to watch the underlying terminal events
|
||||||
|
func (t *Terminal) HandleEvent(ev vaxis.Event) {
|
||||||
|
if t.isClosed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch ev := ev.(type) {
|
||||||
|
case vaxis.Redraw:
|
||||||
|
if atomic.LoadInt32(&t.visible) > 0 {
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
case term.EventTitle:
|
||||||
|
if t.OnTitle != nil {
|
||||||
|
t.OnTitle(string(ev))
|
||||||
|
}
|
||||||
|
case term.EventClosed:
|
||||||
|
t.Close()
|
||||||
|
ui.Invalidate()
|
||||||
|
case term.EventBell:
|
||||||
|
aerc.Beep()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (term *Terminal) Event(event vaxis.Event) bool {
|
||||||
|
if term.OnEvent != nil {
|
||||||
|
if term.OnEvent(event) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if term.isClosed() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
term.vterm.Update(event)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Align struct {
|
||||||
|
Pos app.AlignPosition `opt:"pos" metavar:"top|center|bottom" action:"ParsePos" complete:"CompletePos" desc:"Position."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Align{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Align) Description() string {
|
||||||
|
return "Align the message list view."
|
||||||
|
}
|
||||||
|
|
||||||
|
var posNames []string = []string{"top", "center", "bottom"}
|
||||||
|
|
||||||
|
func (a *Align) ParsePos(arg string) error {
|
||||||
|
switch arg {
|
||||||
|
case "top":
|
||||||
|
a.Pos = app.AlignTop
|
||||||
|
case "center":
|
||||||
|
a.Pos = app.AlignCenter
|
||||||
|
case "bottom":
|
||||||
|
a.Pos = app.AlignBottom
|
||||||
|
default:
|
||||||
|
return errors.New("invalid alignment")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Align) CompletePos(arg string) []string {
|
||||||
|
return commands.FilterList(posNames, arg, commands.QuoteSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Align) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Align) Aliases() []string {
|
||||||
|
return []string{"align"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Align) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("no account selected")
|
||||||
|
}
|
||||||
|
msgList := acct.Messages()
|
||||||
|
if msgList == nil {
|
||||||
|
return errors.New("no message list available")
|
||||||
|
}
|
||||||
|
msgList.AlignMessage(a.Pos)
|
||||||
|
ui.Invalidate()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
"git.sr.ht/~rjarry/go-opt/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChangeFolder struct {
|
||||||
|
Account string `opt:"-a" complete:"CompleteAccount" desc:"Change to specified account."`
|
||||||
|
Folder string `opt:"..." complete:"CompleteFolderAndNotmuch" desc:"Folder name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(ChangeFolder{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChangeFolder) Description() string {
|
||||||
|
return "Change the folder shown in the message list."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChangeFolder) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChangeFolder) Aliases() []string {
|
||||||
|
return []string{"cf"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ChangeFolder) CompleteAccount(arg string) []string {
|
||||||
|
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ChangeFolder) CompleteFolderAndNotmuch(arg string) []string {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
retval := commands.FilterList(
|
||||||
|
acct.Directories().List(), arg,
|
||||||
|
func(s string) string {
|
||||||
|
dir := acct.Directories().Directory(s)
|
||||||
|
if dir != nil && dir.Role != models.QueryRole {
|
||||||
|
s = opt.QuoteArg(s)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if acct.AccountConfig().Backend == "notmuch" {
|
||||||
|
notmuchcomps := handleNotmuchComplete(arg)
|
||||||
|
for _, prefix := range notmuch_search_terms {
|
||||||
|
if strings.HasPrefix(arg, prefix) {
|
||||||
|
return notmuchcomps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retval = append(retval, notmuchcomps...)
|
||||||
|
|
||||||
|
}
|
||||||
|
return retval
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ChangeFolder) Execute([]string) error {
|
||||||
|
var target string
|
||||||
|
var acct *app.AccountView
|
||||||
|
var err error
|
||||||
|
|
||||||
|
args := opt.LexArgs(c.Folder)
|
||||||
|
|
||||||
|
if c.Account != "" {
|
||||||
|
acct, err = app.Account(c.Account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
acct = app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.Count() == 0 {
|
||||||
|
return errors.New("<folder> is required. Usage: cf [-a <account>] <folder>")
|
||||||
|
}
|
||||||
|
|
||||||
|
if acct.AccountConfig().Backend == "notmuch" {
|
||||||
|
// With notmuch, :cf can change to a "dynamic folder" that
|
||||||
|
// contains the result of a query. Preserve the entered
|
||||||
|
// arguments verbatim.
|
||||||
|
target = args.String()
|
||||||
|
} else {
|
||||||
|
if args.Count() != 1 {
|
||||||
|
return errors.New("Unexpected argument(s). Usage: cf [-a <account>] <folder>")
|
||||||
|
}
|
||||||
|
target = args.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize := func(msg types.WorkerMessage) {
|
||||||
|
handleDirOpenResponse(acct, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
dirlist := acct.Directories()
|
||||||
|
if dirlist == nil {
|
||||||
|
return errors.New("No directory list found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if target == "-" {
|
||||||
|
dir := dirlist.Previous()
|
||||||
|
if dir != "" {
|
||||||
|
target = dir
|
||||||
|
} else {
|
||||||
|
return errors.New("No previous folder to return to")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dirlist.Open(target, "", 0*time.Second, finalize, false)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDirOpenResponse(acct *app.AccountView, msg types.WorkerMessage) {
|
||||||
|
// As we're waiting for the worker to report status we must run
|
||||||
|
// the rest of the actions in this callback.
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Error:
|
||||||
|
app.PushError(msg.Error.Error())
|
||||||
|
case *types.Done:
|
||||||
|
// reset store filtering if we switched folders
|
||||||
|
store := acct.Store()
|
||||||
|
if store != nil {
|
||||||
|
store.ApplyClear()
|
||||||
|
acct.SetStatus(state.SearchFilterClear())
|
||||||
|
}
|
||||||
|
// focus account tab
|
||||||
|
acct.Select()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CheckMail struct{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(CheckMail{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CheckMail) Description() string {
|
||||||
|
return "Check for new mail on the selected account."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CheckMail) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CheckMail) Aliases() []string {
|
||||||
|
return []string{"check-mail"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CheckMail) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
acct.CheckMailReset()
|
||||||
|
acct.CheckMail()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Clear struct {
|
||||||
|
Selected bool `opt:"-s" desc:"Select first message after clearing."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Clear{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Clear) Description() string {
|
||||||
|
return "Clear the current search or filter criteria."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Clear) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Clear) Aliases() []string {
|
||||||
|
return []string{"clear"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Clear) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
store := acct.Store()
|
||||||
|
if store == nil {
|
||||||
|
return errors.New("Cannot perform action. Messages still loading")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Selected {
|
||||||
|
defer store.Select("")
|
||||||
|
}
|
||||||
|
store.ApplyClear()
|
||||||
|
acct.SetStatus(state.SearchFilterClear())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
gomail "net/mail"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/emersion/go-message/mail"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Compose struct {
|
||||||
|
Headers string `opt:"-H" action:"ParseHeader" desc:"Add the specified header to the message."`
|
||||||
|
Template string `opt:"-T" complete:"CompleteTemplate" desc:"Template name."`
|
||||||
|
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
|
||||||
|
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
|
||||||
|
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
|
||||||
|
Body string `opt:"..." required:"false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Compose{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Compose) Description() string {
|
||||||
|
return "Open the compose window to write a new email."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Compose) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Compose) ParseHeader(arg string) error {
|
||||||
|
if strings.Contains(arg, ":") {
|
||||||
|
// ensure first colon is followed by a single space
|
||||||
|
re := regexp.MustCompile(`^(.*?):\s*(.*)`)
|
||||||
|
c.Headers += re.ReplaceAllString(arg, "$1: $2\r\n")
|
||||||
|
} else {
|
||||||
|
c.Headers += arg + ":\r\n"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Compose) CompleteTemplate(arg string) []string {
|
||||||
|
return commands.GetTemplates(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Compose) Aliases() []string {
|
||||||
|
return []string{"compose"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Compose) Execute(args []string) error {
|
||||||
|
if c.Headers != "" {
|
||||||
|
if c.Body != "" {
|
||||||
|
c.Body = c.Headers + "\r\n" + c.Body
|
||||||
|
} else {
|
||||||
|
c.Body = c.Headers + "\r\n\r\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Template == "" {
|
||||||
|
c.Template = config.Templates.NewMessage
|
||||||
|
}
|
||||||
|
editHeaders := (config.Compose.EditHeaders || c.Edit) && !c.NoEdit
|
||||||
|
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := gomail.ReadMessage(strings.NewReader(c.Body))
|
||||||
|
if errors.Is(err, io.EOF) { // completely empty
|
||||||
|
msg = &gomail.Message{Body: strings.NewReader("")}
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("mail.ReadMessage: %w", err)
|
||||||
|
}
|
||||||
|
headers := mail.HeaderFromMap(msg.Header)
|
||||||
|
|
||||||
|
composer, err := app.NewComposer(acct,
|
||||||
|
acct.AccountConfig(), acct.Worker(), editHeaders,
|
||||||
|
c.Template, &headers, nil, msg.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
composer.Tab = app.NewTab(composer, "New email")
|
||||||
|
if c.SkipEditor {
|
||||||
|
composer.Terminal().Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Connection struct{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Connection{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Connection) Description() string {
|
||||||
|
return "Disconnect or reconnect the current account."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Connection) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Connection) Aliases() []string {
|
||||||
|
return []string{"connect", "disconnect"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Connection) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
cb := func(msg types.WorkerMessage) {
|
||||||
|
acct.SetStatus(state.ConnectionActivity(""))
|
||||||
|
}
|
||||||
|
if args[0] == "connect" {
|
||||||
|
acct.Worker().PostAction(&types.Connect{}, cb)
|
||||||
|
acct.SetStatus(state.ConnectionActivity("Connecting..."))
|
||||||
|
} else {
|
||||||
|
acct.Worker().PostAction(&types.Disconnect{}, cb)
|
||||||
|
acct.SetStatus(state.ConnectionActivity("Disconnecting..."))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExpandCollapseFolder struct {
|
||||||
|
Folder string `opt:"folder" required:"false" complete:"CompleteFolder" desc:"Folder name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(ExpandCollapseFolder{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ExpandCollapseFolder) Description() string {
|
||||||
|
return "Expand or collapse the current folder."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ExpandCollapseFolder) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ExpandCollapseFolder) Aliases() []string {
|
||||||
|
return []string{"expand-folder", "collapse-folder"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ExpandCollapseFolder) CompleteFolder(arg string) []string {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return commands.FilterList(acct.Directories().List(), arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ExpandCollapseFolder) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
if e.Folder == "" {
|
||||||
|
e.Folder = acct.Directories().Selected()
|
||||||
|
}
|
||||||
|
if args[0] == "expand-folder" {
|
||||||
|
acct.Directories().ExpandFolder(e.Folder)
|
||||||
|
} else {
|
||||||
|
acct.Directories().CollapseFolder(e.Folder)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
mboxer "git.sr.ht/~rjarry/aerc/worker/mbox"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExportMbox struct {
|
||||||
|
Filename string `opt:"filename" complete:"CompleteFilename" desc:"Output file path."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(ExportMbox{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ExportMbox) Description() string {
|
||||||
|
return "Export messages in the current folder to an mbox file."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ExportMbox) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ExportMbox) Aliases() []string {
|
||||||
|
return []string{"export-mbox"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ExportMbox) CompleteFilename(arg string) []string {
|
||||||
|
return commands.CompletePath(arg, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ExportMbox) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
store := acct.Store()
|
||||||
|
if store == nil {
|
||||||
|
return errors.New("No message store selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Filename = xdg.ExpandHome(e.Filename)
|
||||||
|
|
||||||
|
fi, err := os.Stat(e.Filename)
|
||||||
|
if err == nil && fi.IsDir() {
|
||||||
|
if path := acct.SelectedDirectory(); path != "" {
|
||||||
|
if f := filepath.Base(path); f != "" {
|
||||||
|
e.Filename = filepath.Join(e.Filename, f+".mbox")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.PushStatus("Exporting to "+e.Filename, 10*time.Second)
|
||||||
|
|
||||||
|
// uids of messages to export
|
||||||
|
var uids []models.UID
|
||||||
|
|
||||||
|
// check if something is marked - we export that then
|
||||||
|
msgProvider, ok := app.SelectedTabContent().(app.ProvidesMessages)
|
||||||
|
if !ok {
|
||||||
|
msgProvider = app.SelectedAccount()
|
||||||
|
}
|
||||||
|
if msgProvider != nil {
|
||||||
|
marked, err := msgProvider.MarkedMessages()
|
||||||
|
if err == nil && len(marked) > 0 {
|
||||||
|
uids, err = sortMarkedUids(marked, store)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if no messages were marked, we export everything
|
||||||
|
if len(uids) == 0 {
|
||||||
|
var err error
|
||||||
|
uids, err = sortAllUids(store)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
file, err := os.Create(e.Filename)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to create file: %v", err)
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var ctr uint
|
||||||
|
var retries int
|
||||||
|
|
||||||
|
done := make(chan bool)
|
||||||
|
|
||||||
|
t := time.Now()
|
||||||
|
total := len(uids)
|
||||||
|
|
||||||
|
for len(uids) > 0 {
|
||||||
|
if retries > 0 {
|
||||||
|
if retries > 10 {
|
||||||
|
errorMsg := fmt.Sprintf("too many retries: %d; stopping export", retries)
|
||||||
|
log.Errorf(errorMsg)
|
||||||
|
app.PushError(args[0] + " " + errorMsg)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sleeping := time.Duration(retries * 1e9 * 2)
|
||||||
|
log.Debugf("sleeping for %s before retrying; retries: %d", sleeping, retries)
|
||||||
|
time.Sleep(sleeping)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("fetching %d for export", len(uids))
|
||||||
|
acct.Worker().PostAction(&types.FetchFullMessages{
|
||||||
|
Uids: uids,
|
||||||
|
}, func(msg types.WorkerMessage) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
done <- true
|
||||||
|
case *types.Error:
|
||||||
|
log.Errorf("failed to fetch message: %v", msg.Error)
|
||||||
|
app.PushError(args[0] + " error encountered: " + msg.Error.Error())
|
||||||
|
done <- false
|
||||||
|
case *types.FullMessage:
|
||||||
|
mu.Lock()
|
||||||
|
err := mboxer.Write(file, msg.Content.Reader, "", t)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to write mbox: %v", err)
|
||||||
|
}
|
||||||
|
for i, uid := range uids {
|
||||||
|
if uid == msg.Content.Uid {
|
||||||
|
uids = append(uids[:i], uids[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctr++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if ok := <-done; ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
retries++
|
||||||
|
}
|
||||||
|
statusInfo := fmt.Sprintf("Exported %d of %d messages to %s.", ctr, total, e.Filename)
|
||||||
|
app.PushStatus(statusInfo, 10*time.Second)
|
||||||
|
log.Debugf(statusInfo)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortMarkedUids(marked []models.UID, store *lib.MessageStore) ([]models.UID, error) {
|
||||||
|
lookup := map[models.UID]bool{}
|
||||||
|
for _, uid := range marked {
|
||||||
|
lookup[uid] = true
|
||||||
|
}
|
||||||
|
uids := []models.UID{}
|
||||||
|
iter := store.UidsIterator()
|
||||||
|
for iter.Next() {
|
||||||
|
uid, ok := iter.Value().(models.UID)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("Invalid message UID value")
|
||||||
|
}
|
||||||
|
_, marked := lookup[uid]
|
||||||
|
if marked {
|
||||||
|
uids = append(uids, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortAllUids(store *lib.MessageStore) ([]models.UID, error) {
|
||||||
|
uids := []models.UID{}
|
||||||
|
iter := store.UidsIterator()
|
||||||
|
for iter.Next() {
|
||||||
|
uid, ok := iter.Value().(models.UID)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("Invalid message UID value")
|
||||||
|
}
|
||||||
|
uids = append(uids, uid)
|
||||||
|
}
|
||||||
|
return uids, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
mboxer "git.sr.ht/~rjarry/aerc/worker/mbox"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImportMbox struct {
|
||||||
|
Path string `opt:"path" complete:"CompleteFilename" desc:"Input file path or URL."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(ImportMbox{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ImportMbox) Description() string {
|
||||||
|
return "Import all messages from an (gzipped) mbox file to the current folder."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ImportMbox) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ImportMbox) Aliases() []string {
|
||||||
|
return []string{"import-mbox"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ImportMbox) CompleteFilename(arg string) []string {
|
||||||
|
return commands.CompletePath(arg, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i ImportMbox) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
store := acct.Store()
|
||||||
|
if store == nil {
|
||||||
|
return errors.New("No message store selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
folder := acct.SelectedDirectory()
|
||||||
|
if folder == "" {
|
||||||
|
return errors.New("No directory selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
importFolder := func(r io.ReadCloser) {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
messages, err := mboxer.Read(r)
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var appended uint32
|
||||||
|
for i, m := range messages {
|
||||||
|
done := make(chan bool)
|
||||||
|
var retries int = 4
|
||||||
|
for retries > 0 {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
r, err := m.NewReader()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("could not get reader for uid %d", m.UID())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nbytes, _ := io.Copy(&buf, r)
|
||||||
|
store.Append(
|
||||||
|
folder,
|
||||||
|
models.SeenFlag,
|
||||||
|
time.Now(),
|
||||||
|
&buf,
|
||||||
|
int(nbytes),
|
||||||
|
func(msg types.WorkerMessage) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Unsupported:
|
||||||
|
errMsg := fmt.Sprintf("%s: AppendMessage is unsupported", args[0])
|
||||||
|
log.Errorf(errMsg)
|
||||||
|
app.PushError(errMsg)
|
||||||
|
return
|
||||||
|
case *types.Error:
|
||||||
|
log.Errorf("AppendMessage failed: %v", msg.Error)
|
||||||
|
done <- false
|
||||||
|
case *types.Done:
|
||||||
|
atomic.AddUint32(&appended, 1)
|
||||||
|
done <- true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ok := <-done:
|
||||||
|
if ok {
|
||||||
|
retries = 0
|
||||||
|
} else {
|
||||||
|
// error encountered; try to append again after a quick nap
|
||||||
|
retries -= 1
|
||||||
|
sleeping := time.Duration((5 - retries) * 1e9)
|
||||||
|
|
||||||
|
log.Debugf("sleeping for %s before append message %d again", sleeping, i)
|
||||||
|
time.Sleep(sleeping)
|
||||||
|
}
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
log.Warnf("timed-out; appended %d of %d", appended, len(messages))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
infoStr := fmt.Sprintf("%s: imported %d of %d successfully.", args[0], appended, len(messages))
|
||||||
|
log.Debugf(infoStr)
|
||||||
|
app.PushSuccess(infoStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf []byte
|
||||||
|
|
||||||
|
path := i.Path
|
||||||
|
if ok, err := regexp.MatchString("^(http[s]\\:|www\\.)", path); ok && err == nil {
|
||||||
|
resp, err := http.Get(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buf, err = io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
path = xdg.ExpandHome(path)
|
||||||
|
buf, err = os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var r io.ReadCloser
|
||||||
|
|
||||||
|
// detect gzip format compressed files as specified in RFC 1952
|
||||||
|
if len(buf) >= 2 && buf[0] == 0x1f && buf[1] == 0x8b {
|
||||||
|
var err error
|
||||||
|
r, err = gzip.NewReader(bytes.NewReader(buf))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
r = io.NopCloser(bytes.NewReader(buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
statusInfo := fmt.Sprintln("Importing", path, "to folder", folder)
|
||||||
|
app.PushStatus(statusInfo, 10*time.Second)
|
||||||
|
log.Debugf(statusInfo)
|
||||||
|
|
||||||
|
if len(store.Uids()) > 0 {
|
||||||
|
confirm := app.NewSelectorDialog(
|
||||||
|
"Selected directory is not empty",
|
||||||
|
fmt.Sprintf("Import mbox file to %s anyways?", folder),
|
||||||
|
[]string{"No", "Yes"}, 0, app.SelectedAccountUiConfig(),
|
||||||
|
func(option string, err error) {
|
||||||
|
app.CloseDialog()
|
||||||
|
if option == "Yes" {
|
||||||
|
go importFolder(r)
|
||||||
|
} else {
|
||||||
|
_ = r.Close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
app.AddDialog(confirm)
|
||||||
|
} else {
|
||||||
|
go importFolder(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
"git.sr.ht/~rjarry/go-opt/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MakeDir struct {
|
||||||
|
Folder string `opt:"folder" complete:"CompleteFolder" desc:"Folder name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(MakeDir{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MakeDir) Description() string {
|
||||||
|
return "Create and change to a new folder."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MakeDir) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MakeDir) Aliases() []string {
|
||||||
|
return []string{"mkdir"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MakeDir) CompleteFolder(arg string) []string {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sep := app.SelectedAccount().Worker().PathSeparator()
|
||||||
|
return commands.FilterList(
|
||||||
|
acct.Directories().List(), arg,
|
||||||
|
func(s string) string {
|
||||||
|
return opt.QuoteArg(s) + sep
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MakeDir) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
acct.Worker().PostAction(&types.CreateDirectory{
|
||||||
|
Directory: m.Folder,
|
||||||
|
}, func(msg types.WorkerMessage) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
app.PushStatus("Directory created.", 10*time.Second)
|
||||||
|
acct.Directories().Open(m.Folder, "", 0, nil, false)
|
||||||
|
case *types.Error:
|
||||||
|
app.PushError(msg.Error.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NextPrevFolder struct {
|
||||||
|
Offset int `opt:"n" default:"1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(NextPrevFolder{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevFolder) Description() string {
|
||||||
|
return "Cycle to the next or previous folder shown in the sidebar."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevFolder) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevFolder) Aliases() []string {
|
||||||
|
return []string{"next-folder", "prev-folder"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (np NextPrevFolder) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
if args[0] == "prev-folder" {
|
||||||
|
acct.Directories().NextPrev(-np.Offset)
|
||||||
|
} else {
|
||||||
|
acct.Directories().NextPrev(np.Offset)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NextPrevResult struct{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(NextPrevResult{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevResult) Description() string {
|
||||||
|
return "Select the next or previous search result."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevResult) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevResult) Aliases() []string {
|
||||||
|
return []string{"next-result", "prev-result"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevResult) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
if args[0] == "prev-result" {
|
||||||
|
store := acct.Store()
|
||||||
|
if store != nil {
|
||||||
|
store.PrevResult()
|
||||||
|
}
|
||||||
|
ui.Invalidate()
|
||||||
|
} else {
|
||||||
|
store := acct.Store()
|
||||||
|
if store != nil {
|
||||||
|
store.NextResult()
|
||||||
|
}
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NextPrevMsg struct {
|
||||||
|
Amount int `opt:"n" default:"1" metavar:"<n>[%]" action:"ParseAmount"`
|
||||||
|
Percent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(NextPrevMsg{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevMsg) Description() string {
|
||||||
|
return "Select the next or previous message in the message list."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevMsg) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||||
|
}
|
||||||
|
|
||||||
|
func (np *NextPrevMsg) ParseAmount(arg string) error {
|
||||||
|
if strings.HasSuffix(arg, "%") {
|
||||||
|
np.Percent = true
|
||||||
|
arg = strings.TrimSuffix(arg, "%")
|
||||||
|
}
|
||||||
|
i, err := strconv.ParseInt(arg, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
np.Amount = int(i)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevMsg) Aliases() []string {
|
||||||
|
return []string{"next", "next-message", "prev", "prev-message"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (np NextPrevMsg) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
store := acct.Store()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("No message store set.")
|
||||||
|
}
|
||||||
|
|
||||||
|
n := np.Amount
|
||||||
|
if np.Percent {
|
||||||
|
n = int(float64(acct.Messages().Height()) * (float64(n) / 100.0))
|
||||||
|
}
|
||||||
|
if args[0] == "prev-message" || args[0] == "prev" {
|
||||||
|
store.NextPrev(-n)
|
||||||
|
} else {
|
||||||
|
store.NextPrev(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mv, ok := app.SelectedTabContent().(*app.MessageViewer); ok {
|
||||||
|
reloadViewer := func(nextMsg *models.MessageInfo) {
|
||||||
|
if nextMsg.Error != nil {
|
||||||
|
app.PushError(nextMsg.Error.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lib.NewMessageStoreView(nextMsg, mv.MessageView().SeenFlagSet(),
|
||||||
|
store, app.CryptoProvider(), app.DecryptKeys,
|
||||||
|
func(view lib.MessageView, err error) {
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextMv, err := app.NewMessageViewer(acct, view)
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.ReplaceTab(mv, nextMv,
|
||||||
|
nextMsg.Envelope.Subject, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if nextMsg := store.Selected(); nextMsg != nil {
|
||||||
|
reloadViewer(nextMsg)
|
||||||
|
} else {
|
||||||
|
store.FetchHeaders([]models.UID{store.SelectedUid()},
|
||||||
|
func(msg types.WorkerMessage) {
|
||||||
|
if m, ok := msg.(*types.MessageInfo); ok {
|
||||||
|
reloadViewer(m.Info)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Invalidate()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Query struct {
|
||||||
|
Account string `opt:"-a" complete:"CompleteAccount" desc:"Account name."`
|
||||||
|
Name string `opt:"-n" desc:"Force name of virtual folder."`
|
||||||
|
Force bool `opt:"-f" desc:"Replace existing query if any."`
|
||||||
|
Query string `opt:"..." complete:"CompleteNotmuch" desc:"Notmuch query."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Query{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Query) Description() string {
|
||||||
|
return "Create a virtual folder using the specified notmuch query."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Query) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Query) Aliases() []string {
|
||||||
|
return []string{"query"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Query) CompleteAccount(arg string) []string {
|
||||||
|
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q Query) Execute([]string) error {
|
||||||
|
var acct *app.AccountView
|
||||||
|
|
||||||
|
if q.Account == "" {
|
||||||
|
acct = app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
acct, err = app.Account(q.Account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if acct.AccountConfig().Backend != "notmuch" {
|
||||||
|
return errors.New(":query is only available for notmuch accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize := func(msg types.WorkerMessage) {
|
||||||
|
handleDirOpenResponse(acct, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := q.Name
|
||||||
|
if name == "" {
|
||||||
|
name = q.Query
|
||||||
|
}
|
||||||
|
acct.Directories().Open(name, q.Query, 0*time.Second, finalize, q.Force)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Query) CompleteNotmuch(arg string) []string {
|
||||||
|
return handleNotmuchComplete(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var notmuch_search_terms = []string{
|
||||||
|
"from:",
|
||||||
|
"to:",
|
||||||
|
"tag:",
|
||||||
|
"date:",
|
||||||
|
"attachment:",
|
||||||
|
"mimetype:",
|
||||||
|
"subject:",
|
||||||
|
"body:",
|
||||||
|
"id:",
|
||||||
|
"thread:",
|
||||||
|
"folder:",
|
||||||
|
"path:",
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleNotmuchComplete(arg string) []string {
|
||||||
|
prefixes := []string{"from:", "to:"}
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
if strings.HasPrefix(arg, prefix) {
|
||||||
|
arg = strings.TrimPrefix(arg, prefix)
|
||||||
|
return commands.FilterList(
|
||||||
|
commands.GetAddress(arg), arg,
|
||||||
|
func(v string) string { return prefix + v },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixes = []string{"tag:"}
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
if strings.HasPrefix(arg, prefix) {
|
||||||
|
arg = strings.TrimPrefix(arg, prefix)
|
||||||
|
return commands.FilterList(
|
||||||
|
commands.GetLabels(arg), arg,
|
||||||
|
func(v string) string { return prefix + v },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixes = []string{"path:", "folder:"}
|
||||||
|
dbPath := strings.TrimPrefix(app.SelectedAccount().AccountConfig().Source, "notmuch://")
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
if strings.HasPrefix(arg, prefix) {
|
||||||
|
arg = strings.TrimPrefix(arg, prefix)
|
||||||
|
return commands.FilterList(
|
||||||
|
commands.CompletePath(dbPath+arg, true), arg,
|
||||||
|
func(v string) string { return prefix + strings.TrimPrefix(v, dbPath) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands.FilterList(notmuch_search_terms, arg, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Recover struct {
|
||||||
|
Force bool `opt:"-f" desc:"Delete recovered file after opening the composer."`
|
||||||
|
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
|
||||||
|
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
|
||||||
|
File string `opt:"file" complete:"CompleteFile" desc:"Recover file path."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Recover{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Recover) Description() string {
|
||||||
|
return "Resume composing a message that was not sent nor postponed."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Recover) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Recover) Aliases() []string {
|
||||||
|
return []string{"recover"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Recover) Options() string {
|
||||||
|
return "feE"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Recover) CompleteFile(arg string) []string {
|
||||||
|
// file name of temp file is hard-coded in the NewComposer() function
|
||||||
|
files, err := filepath.Glob(
|
||||||
|
filepath.Join(os.TempDir(), "aerc-compose-*.eml"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return commands.FilterList(files, arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Recover) Execute(args []string) error {
|
||||||
|
file, err := os.Open(r.File)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
editHeaders := (config.Compose.EditHeaders || r.Edit) && !r.NoEdit
|
||||||
|
|
||||||
|
composer, err := app.NewComposer(acct,
|
||||||
|
acct.AccountConfig(), acct.Worker(), editHeaders,
|
||||||
|
"", nil, nil, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
composer.Tab = app.NewTab(composer, "Recovered")
|
||||||
|
|
||||||
|
// remove file if force flag is set
|
||||||
|
if r.Force {
|
||||||
|
err = os.Remove(r.File)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
"git.sr.ht/~rjarry/go-opt/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RemoveDir struct {
|
||||||
|
Force bool `opt:"-f" desc:"Remove the directory even if it contains messages."`
|
||||||
|
Folder string `opt:"folder" complete:"CompleteFolder" required:"false" desc:"Folder name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(RemoveDir{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RemoveDir) Description() string {
|
||||||
|
return "Remove folder."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RemoveDir) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RemoveDir) Aliases() []string {
|
||||||
|
return []string{"rmdir"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RemoveDir) CompleteFolder(arg string) []string {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return commands.FilterList(acct.Directories().List(), arg, opt.QuoteArg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RemoveDir) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
current := acct.Directories().SelectedDirectory()
|
||||||
|
toRemove := current
|
||||||
|
if r.Folder != "" {
|
||||||
|
toRemove = acct.Directories().Directory(r.Folder)
|
||||||
|
if toRemove == nil {
|
||||||
|
return fmt.Errorf("No such directory: %s", r.Folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
role := toRemove.Role
|
||||||
|
|
||||||
|
// Check for any messages in the directory.
|
||||||
|
if role != models.QueryRole && toRemove.Exists > 0 && !r.Force {
|
||||||
|
return errors.New("Refusing to remove non-empty directory; use -f")
|
||||||
|
}
|
||||||
|
|
||||||
|
if role == models.VirtualRole {
|
||||||
|
return errors.New("Cannot remove a virtual node")
|
||||||
|
}
|
||||||
|
|
||||||
|
if toRemove != current {
|
||||||
|
r.remove(acct, toRemove, func() {})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
curDir := current.Name
|
||||||
|
var newDir string
|
||||||
|
dirFound := false
|
||||||
|
|
||||||
|
oldDir := acct.Directories().Previous()
|
||||||
|
if oldDir != "" {
|
||||||
|
present := false
|
||||||
|
for _, dir := range acct.Directories().List() {
|
||||||
|
if dir == oldDir {
|
||||||
|
present = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldDir != curDir && present {
|
||||||
|
newDir = oldDir
|
||||||
|
dirFound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultDir := acct.AccountConfig().Default
|
||||||
|
if !dirFound && defaultDir != curDir {
|
||||||
|
for _, dir := range acct.Directories().List() {
|
||||||
|
if defaultDir == dir {
|
||||||
|
newDir = dir
|
||||||
|
dirFound = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dirFound {
|
||||||
|
for _, dir := range acct.Directories().List() {
|
||||||
|
if dir != curDir {
|
||||||
|
newDir = dir
|
||||||
|
dirFound = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dirFound {
|
||||||
|
return errors.New("No directory to move to afterwards!")
|
||||||
|
}
|
||||||
|
|
||||||
|
reopenCurrentDir := func() { acct.Directories().Open(curDir, "", 0, nil, false) }
|
||||||
|
|
||||||
|
acct.Directories().Open(newDir, "", 0, func(msg types.WorkerMessage) {
|
||||||
|
switch msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
break
|
||||||
|
case *types.Error:
|
||||||
|
app.PushError("Could not change directory")
|
||||||
|
reopenCurrentDir()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.remove(acct, toRemove, reopenCurrentDir)
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RemoveDir) remove(acct *app.AccountView, dir *models.Directory, onErr func()) {
|
||||||
|
acct.Worker().PostAction(&types.RemoveDirectory{
|
||||||
|
Directory: dir.Name,
|
||||||
|
Quiet: r.Force,
|
||||||
|
}, func(msg types.WorkerMessage) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
app.PushStatus("Directory removed.", 10*time.Second)
|
||||||
|
case *types.Error:
|
||||||
|
app.PushError(msg.Error.Error())
|
||||||
|
onErr()
|
||||||
|
case *types.Unsupported:
|
||||||
|
app.PushError(":rmdir is not supported by the backend.")
|
||||||
|
onErr()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/parse"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/imap/extensions/xgmext"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SearchFilter struct {
|
||||||
|
Read bool `opt:"-r" action:"ParseRead" desc:"Search for read messages."`
|
||||||
|
Unread bool `opt:"-u" action:"ParseUnread" desc:"Search for unread messages."`
|
||||||
|
Body bool `opt:"-b" desc:"Search in the body of the messages."`
|
||||||
|
All bool `opt:"-a" desc:"Search in the entire text of the messages."`
|
||||||
|
UseExtension bool `opt:"-e" desc:"Use custom search backend extension."`
|
||||||
|
Headers textproto.MIMEHeader `opt:"-H" action:"ParseHeader" metavar:"<header>:<value>" desc:"Search for messages with the specified header."`
|
||||||
|
WithFlags models.Flags `opt:"-x" action:"ParseFlag" complete:"CompleteFlag" desc:"Search messages with specified flag."`
|
||||||
|
WithoutFlags models.Flags `opt:"-X" action:"ParseNotFlag" complete:"CompleteFlag" desc:"Search messages without specified flag."`
|
||||||
|
To []string `opt:"-t" action:"ParseTo" complete:"CompleteAddress" desc:"Search for messages To:<address>."`
|
||||||
|
From []string `opt:"-f" action:"ParseFrom" complete:"CompleteAddress" desc:"Search for messages From:<address>."`
|
||||||
|
Cc []string `opt:"-c" action:"ParseCc" complete:"CompleteAddress" desc:"Search for messages Cc:<address>."`
|
||||||
|
StartDate time.Time `opt:"-d" action:"ParseDate" complete:"CompleteDate" desc:"Search for messages within a particular date range."`
|
||||||
|
EndDate time.Time
|
||||||
|
Terms string `opt:"..." required:"false" complete:"CompleteTerms" desc:"Search term."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(SearchFilter{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SearchFilter) Description() string {
|
||||||
|
return "Search or filter the current folder."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SearchFilter) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SearchFilter) Aliases() []string {
|
||||||
|
return []string{"search", "filter"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*SearchFilter) CompleteFlag(arg string) []string {
|
||||||
|
return commands.FilterList(commands.GetFlagList(), arg, commands.QuoteSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*SearchFilter) CompleteAddress(arg string) []string {
|
||||||
|
return commands.FilterList(commands.GetAddress(arg), arg, commands.QuoteSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*SearchFilter) CompleteDate(arg string) []string {
|
||||||
|
return commands.FilterList(commands.GetDateList(), arg, commands.QuoteSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchFilter) CompleteTerms(arg string) []string {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if acct.AccountConfig().Backend == "notmuch" {
|
||||||
|
return handleNotmuchComplete(arg)
|
||||||
|
}
|
||||||
|
caps := acct.Worker().Backend.Capabilities()
|
||||||
|
if caps != nil && caps.Has("X-GM-EXT-1") && s.UseExtension {
|
||||||
|
return handleXGMEXTComplete(arg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchFilter) ParseRead(arg string) error {
|
||||||
|
s.WithFlags |= models.SeenFlag
|
||||||
|
s.WithoutFlags &^= models.SeenFlag
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchFilter) ParseUnread(arg string) error {
|
||||||
|
s.WithFlags &^= models.SeenFlag
|
||||||
|
s.WithoutFlags |= models.SeenFlag
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var flagValues = map[string]models.Flags{
|
||||||
|
"seen": models.SeenFlag,
|
||||||
|
"answered": models.AnsweredFlag,
|
||||||
|
"forwarded": models.ForwardedFlag,
|
||||||
|
"flagged": models.FlaggedFlag,
|
||||||
|
"draft": models.DraftFlag,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchFilter) ParseFlag(arg string) error {
|
||||||
|
f, ok := flagValues[strings.ToLower(arg)]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%q unknown flag", arg)
|
||||||
|
}
|
||||||
|
s.WithFlags |= f
|
||||||
|
s.WithoutFlags &^= f
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchFilter) ParseNotFlag(arg string) error {
|
||||||
|
f, ok := flagValues[strings.ToLower(arg)]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%q unknown flag", arg)
|
||||||
|
}
|
||||||
|
s.WithFlags &^= f
|
||||||
|
s.WithoutFlags |= f
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchFilter) ParseHeader(arg string) error {
|
||||||
|
name, value, hasColon := strings.Cut(arg, ":")
|
||||||
|
if !hasColon {
|
||||||
|
return fmt.Errorf("%q invalid syntax", arg)
|
||||||
|
}
|
||||||
|
if s.Headers == nil {
|
||||||
|
s.Headers = make(textproto.MIMEHeader)
|
||||||
|
}
|
||||||
|
s.Headers.Add(name, strings.TrimSpace(value))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchFilter) ParseTo(arg string) error {
|
||||||
|
s.To = append(s.To, arg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchFilter) ParseFrom(arg string) error {
|
||||||
|
s.From = append(s.From, arg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchFilter) ParseCc(arg string) error {
|
||||||
|
s.Cc = append(s.Cc, arg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchFilter) ParseDate(arg string) error {
|
||||||
|
start, end, err := parse.DateRange(arg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.StartDate = start
|
||||||
|
s.EndDate = end
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SearchFilter) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
store := acct.Store()
|
||||||
|
if store == nil {
|
||||||
|
return errors.New("Cannot perform action. Messages still loading")
|
||||||
|
}
|
||||||
|
|
||||||
|
criteria := types.SearchCriteria{
|
||||||
|
WithFlags: s.WithFlags,
|
||||||
|
WithoutFlags: s.WithoutFlags,
|
||||||
|
From: s.From,
|
||||||
|
To: s.To,
|
||||||
|
Cc: s.Cc,
|
||||||
|
Headers: s.Headers,
|
||||||
|
StartDate: s.StartDate,
|
||||||
|
EndDate: s.EndDate,
|
||||||
|
SearchBody: s.Body,
|
||||||
|
SearchAll: s.All,
|
||||||
|
Terms: []string{s.Terms},
|
||||||
|
UseExtension: s.UseExtension,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args[0] == "filter" {
|
||||||
|
if len(args[1:]) == 0 {
|
||||||
|
return Clear{}.Execute([]string{"clear"})
|
||||||
|
}
|
||||||
|
acct.SetStatus(state.FilterActivity("Filtering..."), state.Search(""))
|
||||||
|
store.SetFilter(&criteria)
|
||||||
|
cb := func(msg types.WorkerMessage) {
|
||||||
|
if _, ok := msg.(*types.Done); ok {
|
||||||
|
acct.SetStatus(state.FilterResult(strings.Join(args, " ")))
|
||||||
|
log.Tracef("Filter results: %v", store.Uids())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.Sort(store.GetCurrentSortCriteria(), cb)
|
||||||
|
} else {
|
||||||
|
acct.SetStatus(state.Search("Searching..."))
|
||||||
|
cb := func(uids []models.UID) {
|
||||||
|
acct.SetStatus(state.Search(strings.Join(args, " ")))
|
||||||
|
log.Tracef("Search results: %v", uids)
|
||||||
|
store.ApplySearch(uids)
|
||||||
|
// TODO: Remove when stores have multiple OnUpdate handlers
|
||||||
|
ui.Invalidate()
|
||||||
|
}
|
||||||
|
store.Search(&criteria, cb)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleXGMEXTComplete(arg string) []string {
|
||||||
|
prefixes := []string{"from:", "to:", "deliveredto:", "cc:", "bcc:"}
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
if strings.HasPrefix(arg, prefix) {
|
||||||
|
arg = strings.TrimPrefix(arg, prefix)
|
||||||
|
return commands.FilterList(
|
||||||
|
commands.GetAddress(arg), arg,
|
||||||
|
func(v string) string { return prefix + v },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands.FilterList(xgmext.Terms, arg, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SelectMessage struct {
|
||||||
|
Index int `opt:"n"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(SelectMessage{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SelectMessage) Description() string {
|
||||||
|
return "Select the <N>th message in the message list."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SelectMessage) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SelectMessage) Aliases() []string {
|
||||||
|
return []string{"select", "select-message"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SelectMessage) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
if acct.Messages().Empty() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
acct.Messages().Select(s.Index)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/sort"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Sort struct {
|
||||||
|
Unused struct{} `opt:"-"`
|
||||||
|
// these fields are only used for completion
|
||||||
|
Reverse bool `opt:"-r" desc:"Sort in the reverse order."`
|
||||||
|
Criteria []string `opt:"criteria" complete:"CompleteCriteria" desc:"Sort criterion."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Sort{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Sort) Description() string {
|
||||||
|
return "Sort the message list by the given criteria."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Sort) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Sort) Aliases() []string {
|
||||||
|
return []string{"sort"}
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportedCriteria = []string{
|
||||||
|
"arrival",
|
||||||
|
"cc",
|
||||||
|
"date",
|
||||||
|
"from",
|
||||||
|
"read",
|
||||||
|
"size",
|
||||||
|
"subject",
|
||||||
|
"to",
|
||||||
|
"flagged",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Sort) CompleteCriteria(arg string) []string {
|
||||||
|
return commands.FilterList(supportedCriteria, arg, commands.QuoteSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Sort) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected.")
|
||||||
|
}
|
||||||
|
store := acct.Store()
|
||||||
|
if store == nil {
|
||||||
|
return errors.New("Messages still loading.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c := store.Capabilities(); c != nil {
|
||||||
|
if !c.Sort {
|
||||||
|
return errors.New("Sorting is not available for this backend.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
var sortCriteria []*types.SortCriterion
|
||||||
|
if len(args[1:]) == 0 {
|
||||||
|
sortCriteria = acct.GetSortCriteria()
|
||||||
|
} else {
|
||||||
|
sortCriteria, err = sort.GetSortCriteria(args[1:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
acct.SetStatus(state.Sorting(true))
|
||||||
|
store.Sort(sortCriteria, func(msg types.WorkerMessage) {
|
||||||
|
if _, ok := msg.(*types.Done); ok {
|
||||||
|
acct.SetStatus(state.Sorting(false))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Split struct {
|
||||||
|
Size int `opt:"n" required:"false" action:"ParseSize"`
|
||||||
|
Delta bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Split{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Split) Description() string {
|
||||||
|
return "Split the message list with a preview pane."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Split) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Split) ParseSize(arg string) error {
|
||||||
|
i, err := strconv.ParseInt(arg, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Size = int(i)
|
||||||
|
if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") {
|
||||||
|
s.Delta = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Split) Aliases() []string {
|
||||||
|
return []string{"split", "vsplit", "hsplit"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Split) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
store := app.SelectedAccount().Store()
|
||||||
|
if store == nil {
|
||||||
|
return errors.New("Cannot perform action. Messages still loading")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Size == 0 && acct.SplitSize() == 0 {
|
||||||
|
if args[0] == "split" || args[0] == "hsplit" {
|
||||||
|
s.Size = app.SelectedAccount().Messages().Height() / 4
|
||||||
|
} else {
|
||||||
|
s.Size = app.SelectedAccount().Messages().Width() / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Delta {
|
||||||
|
acct.SetSplitSize(acct.SplitSize() + s.Size)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.Size == acct.SplitSize() {
|
||||||
|
// Repeated commands of the same size have the effect of
|
||||||
|
// toggling the split
|
||||||
|
s.Size = 0
|
||||||
|
}
|
||||||
|
if s.Size < 0 {
|
||||||
|
// Don't allow split to go negative
|
||||||
|
s.Size = 1
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "split", "hsplit":
|
||||||
|
acct.Split(s.Size)
|
||||||
|
case "vsplit":
|
||||||
|
acct.Vsplit(s.Size)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/templates"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ViewMessage struct {
|
||||||
|
Peek bool `opt:"-p" desc:"Peek message without marking it as read."`
|
||||||
|
Background bool `opt:"-b" desc:"Open message in a background tab."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(ViewMessage{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ViewMessage) Description() string {
|
||||||
|
return "View the selected message in a new tab."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ViewMessage) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ViewMessage) Aliases() []string {
|
||||||
|
return []string{"view-message", "view"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v ViewMessage) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
if acct.Messages().Empty() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
store := acct.Messages().Store()
|
||||||
|
msg := acct.Messages().Selected()
|
||||||
|
if msg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, deleted := store.Deleted[msg.Uid]
|
||||||
|
if deleted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if msg.Error != nil {
|
||||||
|
app.PushError(msg.Error.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lib.NewMessageStoreView(
|
||||||
|
msg,
|
||||||
|
!v.Peek && acct.UiConfig().AutoMarkRead,
|
||||||
|
store,
|
||||||
|
app.CryptoProvider(),
|
||||||
|
app.DecryptKeys,
|
||||||
|
func(view lib.MessageView, err error) {
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewer, err := app.NewMessageViewer(acct, view)
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := state.NewDataSetter()
|
||||||
|
data.SetAccount(acct.AccountConfig())
|
||||||
|
data.SetFolder(acct.Directories().SelectedDirectory())
|
||||||
|
data.SetHeaders(msg.RFC822Headers, &models.OriginalMail{})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = templates.Render(acct.UiConfig().TabTitleViewer, &buf,
|
||||||
|
data.Data())
|
||||||
|
if err != nil {
|
||||||
|
acct.PushError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.Background {
|
||||||
|
app.NewBackgroundTab(viewer, buf.String())
|
||||||
|
} else {
|
||||||
|
app.NewTab(viewer, buf.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||||
|
)
|
||||||
|
|
||||||
|
var previousDir string
|
||||||
|
|
||||||
|
type ChangeDirectory struct {
|
||||||
|
Target string `opt:"directory" default:"~" complete:"CompleteTarget" desc:"Target directory."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(ChangeDirectory{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChangeDirectory) Description() string {
|
||||||
|
return "Change aerc's current working directory."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChangeDirectory) Context() CommandContext {
|
||||||
|
return GLOBAL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChangeDirectory) Aliases() []string {
|
||||||
|
return []string{"cd"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ChangeDirectory) CompleteTarget(arg string) []string {
|
||||||
|
return CompletePath(arg, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cd ChangeDirectory) Execute(args []string) error {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cd.Target == "-" {
|
||||||
|
if previousDir == "" {
|
||||||
|
return errors.New("No previous folder to return to")
|
||||||
|
} else {
|
||||||
|
cd.Target = previousDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target := xdg.ExpandHome(cd.Target)
|
||||||
|
if err := os.Chdir(target); err == nil {
|
||||||
|
previousDir = cwd
|
||||||
|
app.UpdateStatus()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Choose struct {
|
||||||
|
Unused struct{} `opt:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(Choose{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Choose) Description() string {
|
||||||
|
return "Prompt to choose from various options."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Choose) Context() CommandContext {
|
||||||
|
return GLOBAL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Choose) Aliases() []string {
|
||||||
|
return []string{"choose"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Choose) Execute(args []string) error {
|
||||||
|
if len(args) < 5 || len(args)%4 != 1 {
|
||||||
|
return chooseUsage(args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
choices := []app.Choice{}
|
||||||
|
for i := 0; i+4 < len(args); i += 4 {
|
||||||
|
if args[i+1] != "-o" {
|
||||||
|
return chooseUsage(args[0])
|
||||||
|
}
|
||||||
|
choices = append(choices, app.Choice{
|
||||||
|
Key: args[i+2],
|
||||||
|
Text: args[i+3],
|
||||||
|
Command: args[i+4],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.RegisterChoices(choices)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseUsage(cmd string) error {
|
||||||
|
return fmt.Errorf("Usage: %s -o <key> <text> <command> [-o <key> <text> <command>]...", cmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Close struct{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(Close{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Close) Description() string {
|
||||||
|
return "Close the focused tab."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Close) Context() CommandContext {
|
||||||
|
return MESSAGE_VIEWER | TERMINAL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Close) Aliases() []string {
|
||||||
|
return []string{"close"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Close) Execute([]string) error {
|
||||||
|
app.RemoveTab(app.SelectedTabContent(), true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"path"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/go-opt/v2"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/templates"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CommandContext uint32
|
||||||
|
|
||||||
|
const (
|
||||||
|
NONE = 1 << iota
|
||||||
|
// available everywhere
|
||||||
|
GLOBAL
|
||||||
|
// only when a message list is focused
|
||||||
|
MESSAGE_LIST
|
||||||
|
// only when a message viewer is focused
|
||||||
|
MESSAGE_VIEWER
|
||||||
|
// only when a message composer editor is focused
|
||||||
|
COMPOSE_EDIT
|
||||||
|
// only when a message composer review screen is focused
|
||||||
|
COMPOSE_REVIEW
|
||||||
|
// only when a terminal
|
||||||
|
TERMINAL
|
||||||
|
)
|
||||||
|
|
||||||
|
func CurrentContext() CommandContext {
|
||||||
|
var context CommandContext = GLOBAL
|
||||||
|
|
||||||
|
switch tab := app.SelectedTabContent().(type) {
|
||||||
|
case *app.AccountView:
|
||||||
|
context |= MESSAGE_LIST
|
||||||
|
case *app.Composer:
|
||||||
|
if tab.Bindings() == "compose::review" {
|
||||||
|
context |= COMPOSE_REVIEW
|
||||||
|
} else {
|
||||||
|
context |= COMPOSE_EDIT
|
||||||
|
}
|
||||||
|
case *app.MessageViewer:
|
||||||
|
context |= MESSAGE_VIEWER
|
||||||
|
case *app.Terminal:
|
||||||
|
context |= TERMINAL
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
type Command interface {
|
||||||
|
Description() string
|
||||||
|
Context() CommandContext
|
||||||
|
Aliases() []string
|
||||||
|
Execute([]string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var allCommands map[string]Command
|
||||||
|
|
||||||
|
func Register(cmd Command) {
|
||||||
|
if allCommands == nil {
|
||||||
|
allCommands = make(map[string]Command)
|
||||||
|
}
|
||||||
|
for _, alias := range cmd.Aliases() {
|
||||||
|
if allCommands[alias] != nil {
|
||||||
|
panic("duplicate command alias: " + alias)
|
||||||
|
}
|
||||||
|
allCommands[alias] = cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ActiveCommands() []Command {
|
||||||
|
var cmds []Command
|
||||||
|
context := CurrentContext()
|
||||||
|
seen := make(map[reflect.Type]bool)
|
||||||
|
|
||||||
|
for _, cmd := range allCommands {
|
||||||
|
t := reflect.TypeOf(cmd)
|
||||||
|
if seen[t] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[t] = true
|
||||||
|
if cmd.Context()&context != 0 {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmds
|
||||||
|
}
|
||||||
|
|
||||||
|
func ActiveCommandNames() []string {
|
||||||
|
var names []string
|
||||||
|
context := CurrentContext()
|
||||||
|
|
||||||
|
for alias, cmd := range allCommands {
|
||||||
|
if cmd.Context()&context != 0 {
|
||||||
|
names = append(names, alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoSuchCommand string
|
||||||
|
|
||||||
|
func (err NoSuchCommand) Error() string {
|
||||||
|
return "Unknown command " + string(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand non-ambiguous command abbreviations.
|
||||||
|
//
|
||||||
|
// q --> quit
|
||||||
|
// ar --> archive
|
||||||
|
// im --> import-mbox
|
||||||
|
func ExpandAbbreviations(name string) (string, Command, error) {
|
||||||
|
context := CurrentContext()
|
||||||
|
name = strings.TrimLeft(name, ": \t")
|
||||||
|
|
||||||
|
cmd, found := allCommands[name]
|
||||||
|
if found && cmd.Context()&context != 0 {
|
||||||
|
return name, cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate Command
|
||||||
|
var candidateName string
|
||||||
|
|
||||||
|
for alias, cmd := range allCommands {
|
||||||
|
if cmd.Context()&context == 0 || !strings.HasPrefix(alias, name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if candidate != nil {
|
||||||
|
// We have more than one command partially
|
||||||
|
// matching the input.
|
||||||
|
return name, nil, NoSuchCommand(name)
|
||||||
|
}
|
||||||
|
// We have a partial match.
|
||||||
|
candidate = cmd
|
||||||
|
candidateName = alias
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidate == nil {
|
||||||
|
return name, nil, NoSuchCommand(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidateName, candidate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveCommand(
|
||||||
|
cmdline string, acct *config.AccountConfig, msg *models.MessageInfo,
|
||||||
|
) (string, Command, error) {
|
||||||
|
cmdline, err := ExpandTemplates(cmdline, acct, msg)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
name, rest, didCut := strings.Cut(cmdline, " ")
|
||||||
|
name, cmd, err := ExpandAbbreviations(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
cmdline = name
|
||||||
|
if didCut {
|
||||||
|
cmdline += " " + rest
|
||||||
|
}
|
||||||
|
return cmdline, cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateData(
|
||||||
|
cfg *config.AccountConfig,
|
||||||
|
msg *models.MessageInfo,
|
||||||
|
) models.TemplateData {
|
||||||
|
var folder *models.Directory
|
||||||
|
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct != nil {
|
||||||
|
folder = acct.Directories().SelectedDirectory()
|
||||||
|
}
|
||||||
|
if cfg == nil && acct != nil {
|
||||||
|
cfg = acct.AccountConfig()
|
||||||
|
}
|
||||||
|
if msg == nil && acct != nil {
|
||||||
|
msg, _ = acct.SelectedMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
data := state.NewDataSetter()
|
||||||
|
data.SetAccount(cfg)
|
||||||
|
data.SetFolder(folder)
|
||||||
|
data.SetInfo(msg, 0, false)
|
||||||
|
if acct != nil {
|
||||||
|
acct.SetStatus(func(s *state.AccountState, _ string) {
|
||||||
|
data.SetState(s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.Data()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExecuteCommand(cmd Command, cmdline string) error {
|
||||||
|
args := opt.LexArgs(cmdline)
|
||||||
|
if args.Count() == 0 {
|
||||||
|
return errors.New("No arguments")
|
||||||
|
}
|
||||||
|
log.Tracef("executing command %s", args.String())
|
||||||
|
// copy zeroed struct
|
||||||
|
tmp := reflect.New(reflect.TypeOf(cmd)).Interface().(Command)
|
||||||
|
if err := opt.ArgsToStruct(args.Clone(), tmp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tmp.Execute(args.Args())
|
||||||
|
}
|
||||||
|
|
||||||
|
// expand template expressions
|
||||||
|
func ExpandTemplates(
|
||||||
|
s string, cfg *config.AccountConfig, msg *models.MessageInfo,
|
||||||
|
) (string, error) {
|
||||||
|
if strings.Contains(s, "{{") && strings.Contains(s, "}}") {
|
||||||
|
t, err := templates.ParseTemplate("execute", s)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateData(cfg, msg)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = templates.Render(t, &buf, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
s = buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTemplateCompletion(
|
||||||
|
cmd string,
|
||||||
|
) ([]string, string, bool) {
|
||||||
|
countLeft := strings.Count(cmd, "{{")
|
||||||
|
if countLeft == 0 {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
countRight := strings.Count(cmd, "}}")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case countLeft > countRight:
|
||||||
|
// complete template terms
|
||||||
|
var i int
|
||||||
|
for i = len(cmd) - 1; i >= 0; i-- {
|
||||||
|
if strings.ContainsRune("{()| ", rune(cmd[i])) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
search, prefix := cmd[i+1:], cmd[:i+1]
|
||||||
|
padding := strings.Repeat(" ",
|
||||||
|
len(search)-len(strings.TrimLeft(search, " ")))
|
||||||
|
options := FilterList(
|
||||||
|
templates.Terms(),
|
||||||
|
strings.TrimSpace(search),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
return options, prefix + padding, true
|
||||||
|
case countLeft == countRight:
|
||||||
|
// expand template
|
||||||
|
s, err := ExpandTemplates(cmd, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("template rendering failed: %v", err)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
return []string{s}, "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCompletions returns the completion options and the command prefix
|
||||||
|
func GetCompletions(
|
||||||
|
cmd Command, args *opt.Args,
|
||||||
|
) (options []opt.Completion, prefix string) {
|
||||||
|
// copy zeroed struct
|
||||||
|
tmp := reflect.New(reflect.TypeOf(cmd)).Interface().(Command)
|
||||||
|
s, err := args.ArgSafe(0)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("completions error: %v", err)
|
||||||
|
return options, prefix
|
||||||
|
}
|
||||||
|
spec := opt.NewCmdSpec(s, tmp)
|
||||||
|
return spec.GetCompletions(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFolders(arg string) []string {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return make([]string, 0)
|
||||||
|
}
|
||||||
|
return FilterList(acct.Directories().List(), arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTemplates(arg string) []string {
|
||||||
|
templates := make(map[string]bool)
|
||||||
|
for _, dir := range config.Templates.TemplateDirs {
|
||||||
|
for _, f := range listDir(dir, false) {
|
||||||
|
if !isDir(path.Join(dir, f)) {
|
||||||
|
templates[f] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(templates))
|
||||||
|
for n := range templates {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return FilterList(names, arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLabels(arg string) []string {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return make([]string, 0)
|
||||||
|
}
|
||||||
|
var prefix string
|
||||||
|
if arg != "" {
|
||||||
|
// + and - are used to denote tag addition / removal and need to
|
||||||
|
// be striped only the last tag should be completed, so that
|
||||||
|
// multiple labels can be selected
|
||||||
|
switch arg[0] {
|
||||||
|
case '+':
|
||||||
|
prefix = "+"
|
||||||
|
case '-':
|
||||||
|
prefix = "-"
|
||||||
|
}
|
||||||
|
arg = strings.TrimLeft(arg, "+-")
|
||||||
|
}
|
||||||
|
return FilterList(acct.Labels(), arg, func(s string) string {
|
||||||
|
return opt.QuoteArg(prefix+s) + " "
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasCaseSmartPrefix checks whether s starts with prefix, using a case
|
||||||
|
// sensitive match if and only if prefix contains upper case letters.
|
||||||
|
func hasCaseSmartPrefix(s, prefix string) bool {
|
||||||
|
if hasUpper(prefix) {
|
||||||
|
return strings.HasPrefix(s, prefix)
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasUpper(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsUpper(r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/completer"
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetAddress uses the address-book-cmd for address completion
|
||||||
|
func GetAddress(search string) []string {
|
||||||
|
var options []string
|
||||||
|
|
||||||
|
cmd := app.SelectedAccount().AccountConfig().AddressBookCmd
|
||||||
|
if cmd == "" {
|
||||||
|
cmd = config.Compose.AddressBookCmd
|
||||||
|
if cmd == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmpl := completer.New(cmd, func(err error) {
|
||||||
|
app.PushError(
|
||||||
|
fmt.Sprintf("could not complete header: %v", err))
|
||||||
|
log.Warnf("could not complete header: %v", err)
|
||||||
|
})
|
||||||
|
|
||||||
|
if cmpl != nil {
|
||||||
|
addrList, _ := cmpl.ForHeader("to")(context.Background(), search)
|
||||||
|
for _, full := range addrList {
|
||||||
|
addr, err := mail.ParseAddress(full.Value)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
options = append(options, addr.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFlagList returns a list of available flags for completion
|
||||||
|
func GetFlagList() []string {
|
||||||
|
return []string{"Seen", "Answered", "Forwarded", "Flagged", "Draft"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDateList returns a list of date terms for completion
|
||||||
|
func GetDateList() []string {
|
||||||
|
return []string{
|
||||||
|
"today", "yesterday", "this_week", "this_month",
|
||||||
|
"this_year", "last_week", "last_month", "last_year",
|
||||||
|
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
|
||||||
|
"Saturday", "Sunday",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operands returns a slice without any option flags or mandatory option
|
||||||
|
// arguments
|
||||||
|
func Operands(args []string, spec string) []string {
|
||||||
|
var result []string
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
if s := args[i]; s == "--" {
|
||||||
|
return args[i+1:]
|
||||||
|
} else if strings.HasPrefix(s, "-") && len(spec) > 0 {
|
||||||
|
r := string(s[len(s)-1]) + ":"
|
||||||
|
if strings.Contains(spec, r) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, args[i])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package commands_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommands_Operand(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
args []string
|
||||||
|
spec string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
args: []string{"cmd", "-a", "-b", "arg1", "-c", "bla"},
|
||||||
|
spec: "ab:c",
|
||||||
|
want: "cmdbla",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: []string{"cmd", "-a", "-b", "arg1", "-c", "--", "bla"},
|
||||||
|
spec: "ab:c",
|
||||||
|
want: "bla",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: []string{"cmd", "-a", "-b", "arg1", "-c", "bla"},
|
||||||
|
spec: "ab:c:",
|
||||||
|
want: "cmd",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: nil,
|
||||||
|
spec: "ab:c:",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i, test := range tests {
|
||||||
|
arg := strings.Join(commands.Operands(test.args, test.spec), "")
|
||||||
|
if arg != test.want {
|
||||||
|
t.Errorf("failed test %d: want '%s', got '%s'", i,
|
||||||
|
test.want, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Abort struct{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Abort{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Abort) Description() string {
|
||||||
|
return "Close the composer without sending."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Abort) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Abort) Aliases() []string {
|
||||||
|
return []string{"abort"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Abort) Execute(args []string) error {
|
||||||
|
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||||
|
app.RemoveTab(composer, true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AttachKey struct{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(AttachKey{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AttachKey) Description() string {
|
||||||
|
return "Attach the public key of the current account."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AttachKey) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AttachKey) Aliases() []string {
|
||||||
|
return []string{"attach-key"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AttachKey) Execute(args []string) error {
|
||||||
|
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||||
|
return composer.SetAttachKey(!composer.AttachKey())
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Attach struct {
|
||||||
|
Menu bool `opt:"-m" desc:"Select files from file-picker-cmd."`
|
||||||
|
Name string `opt:"-r" desc:"<name> <cmd...>: Generate attachment from command output."`
|
||||||
|
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Attachment file path."`
|
||||||
|
Args string `opt:"..." required:"false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Attach{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Attach) Description() string {
|
||||||
|
return "Attach the file at the given path to the email."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Attach) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Attach) Aliases() []string {
|
||||||
|
return []string{"attach"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Attach) CompletePath(arg string) []string {
|
||||||
|
return commands.CompletePath(arg, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Attach) Execute(args []string) error {
|
||||||
|
if a.Menu && a.Name != "" {
|
||||||
|
return errors.New("-m and -r are mutually exclusive")
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case a.Menu:
|
||||||
|
return a.openMenu()
|
||||||
|
case a.Name != "":
|
||||||
|
if a.Path == "" {
|
||||||
|
return errors.New("command is required")
|
||||||
|
}
|
||||||
|
return a.readCommand()
|
||||||
|
default:
|
||||||
|
if a.Args != "" {
|
||||||
|
return errors.New("only a single path is supported")
|
||||||
|
}
|
||||||
|
return a.addPath(a.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Attach) addPath(path string) error {
|
||||||
|
path = xdg.ExpandHome(path)
|
||||||
|
attachments, err := filepath.Glob(path)
|
||||||
|
if err != nil && errors.Is(err, filepath.ErrBadPattern) {
|
||||||
|
log.Warnf("failed to parse as globbing pattern: %v", err)
|
||||||
|
attachments = []string{path}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(path, ".") && !strings.Contains(path, "/.") {
|
||||||
|
log.Debugf("removing hidden files from glob results")
|
||||||
|
for i := len(attachments) - 1; i >= 0; i-- {
|
||||||
|
if strings.HasPrefix(filepath.Base(attachments[i]), ".") {
|
||||||
|
if i == len(attachments)-1 {
|
||||||
|
attachments = attachments[:i]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
attachments = append(attachments[:i], attachments[i+1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||||
|
for _, attach := range attachments {
|
||||||
|
log.Debugf("attaching '%s'", attach)
|
||||||
|
|
||||||
|
pathinfo, err := os.Stat(attach)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to stat file: %v", err)
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return err
|
||||||
|
} else if pathinfo.IsDir() && len(attachments) == 1 {
|
||||||
|
app.PushError("Attachment must be a file, not a directory")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
composer.AddAttachment(attach)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(attachments) == 1 {
|
||||||
|
app.PushSuccess(fmt.Sprintf("Attached %s", path))
|
||||||
|
} else {
|
||||||
|
app.PushSuccess(fmt.Sprintf("Attached %d files", len(attachments)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Attach) openMenu() error {
|
||||||
|
filePickerCmd := config.Compose.FilePickerCmd
|
||||||
|
if filePickerCmd == "" {
|
||||||
|
return fmt.Errorf("no file-picker-cmd defined")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(filePickerCmd, "%s") {
|
||||||
|
filePickerCmd = strings.ReplaceAll(filePickerCmd, "%s", a.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
picks, err := os.CreateTemp("", "aerc-filepicker-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var filepicker *exec.Cmd
|
||||||
|
if strings.Contains(filePickerCmd, "%f") {
|
||||||
|
filePickerCmd = strings.ReplaceAll(filePickerCmd, "%f", picks.Name())
|
||||||
|
filepicker = exec.Command("sh", "-c", filePickerCmd)
|
||||||
|
} else {
|
||||||
|
filepicker = exec.Command("sh", "-c", filePickerCmd+" >&3")
|
||||||
|
filepicker.ExtraFiles = append(filepicker.ExtraFiles, picks)
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := app.NewTerminal(filepicker)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.Focus(true)
|
||||||
|
t.OnClose = func(err error) {
|
||||||
|
defer func() {
|
||||||
|
if err := picks.Close(); err != nil {
|
||||||
|
log.Errorf("error closing file: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(picks.Name()); err != nil {
|
||||||
|
log.Errorf("could not remove tmp file: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
app.CloseDialog()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("terminal closed with error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = picks.Seek(0, io.SeekStart)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("seek failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(picks)
|
||||||
|
for scanner.Scan() {
|
||||||
|
f := strings.TrimSpace(scanner.Text())
|
||||||
|
if _, err := os.Stat(f); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Tracef("File picker attaches: %v", f)
|
||||||
|
err := a.addPath(f)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("attach failed for file %s: %v", f, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.AddDialog(app.DefaultDialog(
|
||||||
|
ui.NewBox(t, "File Picker", "", app.SelectedAccountUiConfig()),
|
||||||
|
))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Attach) readCommand() error {
|
||||||
|
cmd := exec.Command("sh", "-c", a.Path+" "+a.Args)
|
||||||
|
|
||||||
|
data, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Output")
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(bytes.NewReader(data))
|
||||||
|
|
||||||
|
mimeType, mimeParams, err := lib.FindMimeType(a.Name, reader)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "FindMimeType")
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeParams["name"] = a.Name
|
||||||
|
|
||||||
|
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||||
|
err = composer.AddPartAttachment(a.Name, mimeType, mimeParams, reader)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "AddPartAttachment")
|
||||||
|
}
|
||||||
|
|
||||||
|
app.PushSuccess(fmt.Sprintf("Attached %s", a.Name))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CC struct {
|
||||||
|
Recipients string `opt:"recipients" complete:"CompleteAddress" desc:"Recipient from address book."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(CC{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CC) Description() string {
|
||||||
|
return "Add the given address(es) to the Cc or Bcc header."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CC) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CC) Aliases() []string {
|
||||||
|
return []string{"cc", "bcc"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CC) CompleteAddress(arg string) []string {
|
||||||
|
return commands.GetAddress(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CC) Execute(args []string) error {
|
||||||
|
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "cc":
|
||||||
|
return composer.AddEditor("Cc", c.Recipients, true)
|
||||||
|
case "bcc":
|
||||||
|
return composer.AddEditor("Bcc", c.Recipients, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Detach struct {
|
||||||
|
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Attachment file path."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Detach{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Detach) Description() string {
|
||||||
|
return "Detach the file with the given path from the composed email."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Detach) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Detach) Aliases() []string {
|
||||||
|
return []string{"detach"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Detach) CompletePath(arg string) []string {
|
||||||
|
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||||
|
return commands.FilterList(composer.GetAttachments(), arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Detach) Execute(args []string) error {
|
||||||
|
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||||
|
|
||||||
|
if d.Path == "" {
|
||||||
|
// if no attachment is specified, delete the first in the list
|
||||||
|
atts := composer.GetAttachments()
|
||||||
|
if len(atts) > 0 {
|
||||||
|
d.Path = atts[0]
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("No attachments to delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.removePath(d.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Detach) removePath(path string) error {
|
||||||
|
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||||
|
|
||||||
|
// If we don't get an error here, the path was not a pattern.
|
||||||
|
if err := composer.DeleteAttachment(path); err == nil {
|
||||||
|
log.Debugf("detaching '%s'", path)
|
||||||
|
app.PushSuccess(fmt.Sprintf("Detached %s", path))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAttachments := composer.GetAttachments()
|
||||||
|
detached := make([]string, 0, len(currentAttachments))
|
||||||
|
for _, a := range currentAttachments {
|
||||||
|
// Don't use filepath.Glob like :attach does. Not all files
|
||||||
|
// that match the glob are already attached to the message.
|
||||||
|
matches, err := filepath.Match(path, a)
|
||||||
|
if err != nil && errors.Is(err, filepath.ErrBadPattern) {
|
||||||
|
log.Warnf("failed to parse as globbing pattern: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches {
|
||||||
|
log.Debugf("detaching '%s'", a)
|
||||||
|
if err := composer.DeleteAttachment(a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
detached = append(detached, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(detached) == 1 {
|
||||||
|
app.PushSuccess(fmt.Sprintf("Detached %s", detached[0]))
|
||||||
|
} else {
|
||||||
|
app.PushSuccess(fmt.Sprintf("Detached %d files", len(detached)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Edit struct {
|
||||||
|
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
|
||||||
|
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Edit{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Edit) Description() string {
|
||||||
|
return "(Re-)open text editor to edit the message in progress."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Edit) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Edit) Aliases() []string {
|
||||||
|
return []string{"edit"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Edit) Execute(args []string) error {
|
||||||
|
composer, ok := app.SelectedTabContent().(*app.Composer)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("only valid while composing")
|
||||||
|
}
|
||||||
|
|
||||||
|
editHeaders := (config.Compose.EditHeaders || e.Edit) && !e.NoEdit
|
||||||
|
|
||||||
|
err := composer.ShowTerminal(editHeaders)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
composer.FocusTerminal()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Encrypt struct{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Encrypt{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Encrypt) Description() string {
|
||||||
|
return "Toggle encryption of the message to all recipients."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Encrypt) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Encrypt) Aliases() []string {
|
||||||
|
return []string{"encrypt"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Encrypt) Execute(args []string) error {
|
||||||
|
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||||
|
composer.SetEncrypt(!composer.Encrypt())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Header struct {
|
||||||
|
Force bool `opt:"-f" desc:"Overwrite any existing header."`
|
||||||
|
Remove bool `opt:"-d" desc:"Remove the header instead of adding it."`
|
||||||
|
Name string `opt:"name" complete:"CompleteHeaders" desc:"Header name."`
|
||||||
|
Value string `opt:"..." required:"false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var headers = []string{
|
||||||
|
"From",
|
||||||
|
"To",
|
||||||
|
"Cc",
|
||||||
|
"Bcc",
|
||||||
|
"Subject",
|
||||||
|
"Comments",
|
||||||
|
"Keywords",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Header{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Header) Description() string {
|
||||||
|
return "Add or remove the specified email header."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Header) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Header) Aliases() []string {
|
||||||
|
return []string{"header"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Header) Options() string {
|
||||||
|
return "fd"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Header) CompleteHeaders(arg string) []string {
|
||||||
|
return commands.FilterList(headers, arg, commands.QuoteSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Header) Execute(args []string) error {
|
||||||
|
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||||
|
|
||||||
|
name := strings.TrimRight(h.Name, ":")
|
||||||
|
|
||||||
|
if h.Remove {
|
||||||
|
return composer.DelEditor(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.Force {
|
||||||
|
headers, err := composer.PrepareHeader()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if headers.Get(name) != "" && h.Value != "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"Header %s is already set to %q (use -f to overwrite)",
|
||||||
|
name, headers.Get(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return composer.AddEditor(name, h.Value, false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Multipart struct {
|
||||||
|
Remove bool `opt:"-d" desc:"Remove the specified mime/type."`
|
||||||
|
Mime string `opt:"mime" metavar:"<mime/type>" complete:"CompleteMime" desc:"MIME/type name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Multipart{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Multipart) Description() string {
|
||||||
|
return "Convert the message to multipart with the given mime/type part."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Multipart) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Multipart) Aliases() []string {
|
||||||
|
return []string{"multipart"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Multipart) CompleteMime(arg string) []string {
|
||||||
|
var completions []string
|
||||||
|
for mime := range config.Converters {
|
||||||
|
completions = append(completions, mime)
|
||||||
|
}
|
||||||
|
return commands.FilterList(completions, arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Multipart) Execute(args []string) error {
|
||||||
|
composer, ok := app.SelectedTabContent().(*app.Composer)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf(":multipart is only available on the compose::review screen")
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Remove {
|
||||||
|
return composer.RemovePart(m.Mime)
|
||||||
|
} else {
|
||||||
|
_, found := config.Converters[m.Mime]
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("no command defined for MIME type: %s", m.Mime)
|
||||||
|
}
|
||||||
|
err := composer.AppendPart(
|
||||||
|
m.Mime,
|
||||||
|
map[string]string{"Charset": "UTF-8"},
|
||||||
|
// the actual content of the part will be rendered
|
||||||
|
// every time the body of the email is updated
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NextPrevField struct{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(NextPrevField{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevField) Description() string {
|
||||||
|
return "Cycle between header input fields."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevField) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_EDIT
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevField) Aliases() []string {
|
||||||
|
return []string{"next-field", "prev-field"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NextPrevField) Execute(args []string) error {
|
||||||
|
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||||
|
var ok bool
|
||||||
|
if args[0] == "prev-field" {
|
||||||
|
ok = composer.PrevField()
|
||||||
|
} else {
|
||||||
|
ok = composer.NextField()
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s not available when edit-headers=true", args[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Postpone struct {
|
||||||
|
Folder string `opt:"-t" complete:"CompleteFolder" desc:"Override the target folder."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Postpone{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Postpone) Description() string {
|
||||||
|
return "Save the current state of the message to the postpone folder."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Postpone) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Postpone) Aliases() []string {
|
||||||
|
return []string{"postpone"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Postpone) CompleteFolder(arg string) []string {
|
||||||
|
return commands.GetFolders(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Postpone) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
store := acct.Store()
|
||||||
|
if store == nil {
|
||||||
|
return errors.New("No message store selected")
|
||||||
|
}
|
||||||
|
tab := app.SelectedTab()
|
||||||
|
if tab == nil {
|
||||||
|
return errors.New("No tab selected")
|
||||||
|
}
|
||||||
|
composer, _ := tab.Content.(*app.Composer)
|
||||||
|
config := composer.Config()
|
||||||
|
tabName := tab.Name
|
||||||
|
|
||||||
|
targetFolder := config.Postpone
|
||||||
|
if composer.RecalledFrom() != "" {
|
||||||
|
targetFolder = composer.RecalledFrom()
|
||||||
|
}
|
||||||
|
if p.Folder != "" {
|
||||||
|
targetFolder = p.Folder
|
||||||
|
}
|
||||||
|
if targetFolder == "" {
|
||||||
|
return errors.New("No Postpone location configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Tracef("Postponing mail")
|
||||||
|
|
||||||
|
header, err := composer.PrepareHeader()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "PrepareHeader")
|
||||||
|
}
|
||||||
|
header.SetContentType("text/plain", map[string]string{"charset": "UTF-8"})
|
||||||
|
header.Set("Content-Transfer-Encoding", "quoted-printable")
|
||||||
|
worker := composer.Worker()
|
||||||
|
dirs := acct.Directories().List()
|
||||||
|
alreadyCreated := false
|
||||||
|
for _, dir := range dirs {
|
||||||
|
if dir == targetFolder {
|
||||||
|
alreadyCreated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errChan := make(chan string)
|
||||||
|
|
||||||
|
// run this as a goroutine so we can make other progress. The message
|
||||||
|
// will be saved once the directory is created.
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
|
||||||
|
errStr := <-errChan
|
||||||
|
if errStr != "" {
|
||||||
|
app.PushError(errStr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleErr := func(err error) {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
log.Errorf("Postponing failed: %v", err)
|
||||||
|
app.NewTab(composer, tabName)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.RemoveTab(composer, false)
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
err = composer.WriteMessage(header, buf)
|
||||||
|
if err != nil {
|
||||||
|
handleErr(errors.Wrap(err, "WriteMessage"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.Append(
|
||||||
|
targetFolder,
|
||||||
|
models.SeenFlag|models.DraftFlag,
|
||||||
|
time.Now(),
|
||||||
|
buf,
|
||||||
|
buf.Len(),
|
||||||
|
func(msg types.WorkerMessage) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
app.PushStatus("Message postponed.", 10*time.Second)
|
||||||
|
composer.SetPostponed()
|
||||||
|
composer.Close()
|
||||||
|
case *types.Error:
|
||||||
|
handleErr(msg.Error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if !alreadyCreated {
|
||||||
|
// to synchronise the creating of the directory
|
||||||
|
worker.PostAction(&types.CreateDirectory{
|
||||||
|
Directory: targetFolder,
|
||||||
|
}, func(msg types.WorkerMessage) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
errChan <- ""
|
||||||
|
case *types.Error:
|
||||||
|
errChan <- msg.Error.Error()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
errChan <- ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands/mode"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands/msg"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/hooks"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/send"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
"git.sr.ht/~rjarry/go-opt/v2"
|
||||||
|
"github.com/emersion/go-message/mail"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Send struct {
|
||||||
|
Archive string `opt:"-a" action:"ParseArchive" metavar:"flat|year|month" complete:"CompleteArchive" desc:"Archive the message being replied to."`
|
||||||
|
CopyTo []string `opt:"-t" complete:"CompleteFolders" action:"ParseCopyTo" desc:"Override the Copy-To folders."`
|
||||||
|
|
||||||
|
CopyToReplied bool `opt:"-r" desc:"Save sent message to current folder."`
|
||||||
|
NoCopyToReplied bool `opt:"-R" desc:"Do not save sent message to current folder."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Send{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Send) Description() string {
|
||||||
|
return "Send the message using the configured outgoing transport."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Send) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Send) Aliases() []string {
|
||||||
|
return []string{"send"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Send) CompleteArchive(arg string) []string {
|
||||||
|
return commands.FilterList(msg.ARCHIVE_TYPES, arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Send) CompleteFolders(arg string) []string {
|
||||||
|
return commands.GetFolders(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Send) ParseArchive(arg string) error {
|
||||||
|
for _, a := range msg.ARCHIVE_TYPES {
|
||||||
|
if a == arg {
|
||||||
|
s.Archive = arg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("unsupported archive type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Send) ParseCopyTo(arg string) error {
|
||||||
|
o.CopyTo = append(o.CopyTo, strings.Split(arg, ",")...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Send) Execute(args []string) error {
|
||||||
|
tab := app.SelectedTab()
|
||||||
|
if tab == nil {
|
||||||
|
return errors.New("No selected tab")
|
||||||
|
}
|
||||||
|
composer, _ := tab.Content.(*app.Composer)
|
||||||
|
|
||||||
|
err := composer.CheckForMultipartErrors()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
config := composer.Config()
|
||||||
|
|
||||||
|
if len(s.CopyTo) == 0 {
|
||||||
|
s.CopyTo = config.CopyTo
|
||||||
|
}
|
||||||
|
copyToReplied := config.CopyToReplied || (s.CopyToReplied && !s.NoCopyToReplied)
|
||||||
|
|
||||||
|
outgoing, err := config.Outgoing.ConnectionString()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "ReadCredentials(outgoing)")
|
||||||
|
}
|
||||||
|
if outgoing == "" {
|
||||||
|
return errors.New(
|
||||||
|
"No outgoing mail transport configured for this account")
|
||||||
|
}
|
||||||
|
|
||||||
|
header, err := composer.PrepareHeader()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "PrepareHeader")
|
||||||
|
}
|
||||||
|
rcpts, err := listRecipients(header)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "listRecipients")
|
||||||
|
}
|
||||||
|
if len(rcpts) == 0 {
|
||||||
|
return errors.New("Cannot send message with no recipients")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.StripBcc {
|
||||||
|
// Do NOT leak Bcc addresses to all recipients.
|
||||||
|
header.Del("Bcc")
|
||||||
|
}
|
||||||
|
|
||||||
|
uri, err := url.Parse(outgoing)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "url.Parse(outgoing)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var domain string
|
||||||
|
if domain_, ok := config.Params["smtp-domain"]; ok {
|
||||||
|
domain = domain_
|
||||||
|
}
|
||||||
|
from := config.From
|
||||||
|
if config.UseEnvelopeFrom {
|
||||||
|
if fl, _ := header.AddressList("from"); len(fl) != 0 {
|
||||||
|
from = fl[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("send config uri: %s", uri.Redacted())
|
||||||
|
log.Debugf("send config from: %s", from)
|
||||||
|
log.Debugf("send config rcpts: %s", rcpts)
|
||||||
|
log.Debugf("send config domain: %s", domain)
|
||||||
|
|
||||||
|
warnSubject := composer.ShouldWarnSubject()
|
||||||
|
warnAttachment := composer.ShouldWarnAttachment()
|
||||||
|
if warnSubject || warnAttachment {
|
||||||
|
var msg string
|
||||||
|
switch {
|
||||||
|
case warnSubject && warnAttachment:
|
||||||
|
msg = "The subject is empty, and you may have forgotten an attachment."
|
||||||
|
case warnSubject:
|
||||||
|
msg = "The subject is empty."
|
||||||
|
default:
|
||||||
|
msg = "You may have forgotten an attachment."
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := app.NewPrompt(
|
||||||
|
msg+" Abort send? [Y/n] ",
|
||||||
|
func(text string) {
|
||||||
|
if text == "n" || text == "N" {
|
||||||
|
sendHelper(composer, header, uri, domain,
|
||||||
|
from, rcpts, tab.Name, s.CopyTo,
|
||||||
|
s.Archive, copyToReplied)
|
||||||
|
}
|
||||||
|
}, func(ctx context.Context, cmd string) ([]opt.Completion, string) {
|
||||||
|
var comps []opt.Completion
|
||||||
|
if cmd == "" {
|
||||||
|
comps = append(comps, opt.Completion{Value: "y"})
|
||||||
|
comps = append(comps, opt.Completion{Value: "n"})
|
||||||
|
}
|
||||||
|
return comps, ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.PushPrompt(prompt)
|
||||||
|
} else {
|
||||||
|
sendHelper(composer, header, uri, domain, from, rcpts, tab.Name,
|
||||||
|
s.CopyTo, s.Archive, copyToReplied)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendHelper(composer *app.Composer, header *mail.Header, uri *url.URL, domain string,
|
||||||
|
from *mail.Address, rcpts []*mail.Address, tabName string, copyTo []string,
|
||||||
|
archive string, copyToReplied bool,
|
||||||
|
) {
|
||||||
|
// we don't want to block the UI thread while we are sending
|
||||||
|
// so we do everything in a goroutine and hide the composer from the user
|
||||||
|
app.RemoveTab(composer, false)
|
||||||
|
app.PushStatus("Sending...", 10*time.Second)
|
||||||
|
|
||||||
|
// enter no-quit mode
|
||||||
|
mode.NoQuit()
|
||||||
|
|
||||||
|
var shouldCopy bool = (len(copyTo) > 0 || copyToReplied) && !strings.HasPrefix(uri.Scheme, "jmap")
|
||||||
|
var copyBuf bytes.Buffer
|
||||||
|
|
||||||
|
failCh := make(chan error)
|
||||||
|
// writer
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
|
||||||
|
var folders []string
|
||||||
|
folders = append(folders, copyTo...)
|
||||||
|
if copyToReplied && composer.Parent() != nil {
|
||||||
|
folders = append(folders, composer.Parent().Folder)
|
||||||
|
}
|
||||||
|
sender, err := send.NewSender(
|
||||||
|
composer.Worker(), uri, domain, from, rcpts, folders)
|
||||||
|
if err != nil {
|
||||||
|
failCh <- errors.Wrap(err, "send:")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var writer io.Writer = sender
|
||||||
|
|
||||||
|
if shouldCopy {
|
||||||
|
writer = io.MultiWriter(writer, ©Buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = composer.WriteMessage(header, writer)
|
||||||
|
if err != nil {
|
||||||
|
failCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failCh <- sender.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// cleanup + copy to sent
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
|
||||||
|
// leave no-quit mode
|
||||||
|
defer mode.NoQuitDone()
|
||||||
|
|
||||||
|
err := <-failCh
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(strings.ReplaceAll(err.Error(), "\n", " "))
|
||||||
|
app.NewTab(composer, tabName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if shouldCopy {
|
||||||
|
app.PushStatus("Copying to copy-to folders", 10*time.Second)
|
||||||
|
errch := copyToSent(copyTo, copyToReplied, copyBuf.Len(),
|
||||||
|
©Buf, composer)
|
||||||
|
err = <-errch
|
||||||
|
if err != nil {
|
||||||
|
errmsg := fmt.Sprintf(
|
||||||
|
"message sent, but copying to %v failed: %v",
|
||||||
|
copyTo, err.Error())
|
||||||
|
app.PushError(errmsg)
|
||||||
|
composer.SetSent(archive)
|
||||||
|
composer.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.PushStatus("Message sent.", 10*time.Second)
|
||||||
|
composer.SetSent(archive)
|
||||||
|
err = hooks.RunHook(&hooks.MailSent{
|
||||||
|
Account: composer.Account().Name(),
|
||||||
|
Backend: composer.Account().AccountConfig().Backend,
|
||||||
|
Header: header,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to trigger mail-sent hook: %v", err)
|
||||||
|
composer.Account().PushError(fmt.Errorf("[hook.mail-sent] failed: %w", err))
|
||||||
|
}
|
||||||
|
composer.Close()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listRecipients(h *mail.Header) ([]*mail.Address, error) {
|
||||||
|
var rcpts []*mail.Address
|
||||||
|
for _, key := range []string{"to", "cc", "bcc"} {
|
||||||
|
list, err := h.AddressList(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rcpts = append(rcpts, list...)
|
||||||
|
}
|
||||||
|
return rcpts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyToSent(dests []string, copyToReplied bool, n int, msg *bytes.Buffer, composer *app.Composer) <-chan error {
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
acct := composer.Account()
|
||||||
|
if acct == nil {
|
||||||
|
errCh <- errors.New("No account selected")
|
||||||
|
return errCh
|
||||||
|
}
|
||||||
|
store := acct.Store()
|
||||||
|
if store == nil {
|
||||||
|
errCh <- errors.New("No message store selected")
|
||||||
|
return errCh
|
||||||
|
}
|
||||||
|
for _, dest := range dests {
|
||||||
|
store.Append(
|
||||||
|
dest,
|
||||||
|
models.SeenFlag,
|
||||||
|
time.Now(),
|
||||||
|
bytes.NewReader(msg.Bytes()),
|
||||||
|
n,
|
||||||
|
func(msg types.WorkerMessage) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
errCh <- nil
|
||||||
|
case *types.Error:
|
||||||
|
errCh <- msg.Error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if copyToReplied && composer.Parent() != nil {
|
||||||
|
store.Append(
|
||||||
|
composer.Parent().Folder,
|
||||||
|
models.SeenFlag,
|
||||||
|
time.Now(),
|
||||||
|
bytes.NewReader(msg.Bytes()),
|
||||||
|
n,
|
||||||
|
func(msg types.WorkerMessage) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
errCh <- nil
|
||||||
|
case *types.Error:
|
||||||
|
errCh <- msg.Error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return errCh
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Sign struct{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Sign{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Sign) Description() string {
|
||||||
|
return "Sign the message using the account default key."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Sign) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Sign) Aliases() []string {
|
||||||
|
return []string{"sign"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Sign) Execute(args []string) error {
|
||||||
|
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||||
|
|
||||||
|
err := composer.SetSign(!composer.Sign())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusline string
|
||||||
|
|
||||||
|
if composer.Sign() {
|
||||||
|
statusline = "Message will be signed."
|
||||||
|
} else {
|
||||||
|
statusline = "Message will not be signed."
|
||||||
|
}
|
||||||
|
|
||||||
|
app.PushStatus(statusline, 10*time.Second)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountSwitcher interface {
|
||||||
|
SwitchAccount(*app.AccountView) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type SwitchAccount struct {
|
||||||
|
Prev bool `opt:"-p" desc:"Switch to previous account."`
|
||||||
|
Next bool `opt:"-n" desc:"Switch to next account."`
|
||||||
|
Account string `opt:"account" required:"false" complete:"CompleteAccount" desc:"Account name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(SwitchAccount{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SwitchAccount) Description() string {
|
||||||
|
return "Change composing from the specified account."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SwitchAccount) Context() commands.CommandContext {
|
||||||
|
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SwitchAccount) Aliases() []string {
|
||||||
|
return []string{"switch-account"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*SwitchAccount) CompleteAccount(arg string) []string {
|
||||||
|
return commands.FilterList(app.AccountNames(), arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SwitchAccount) Execute(args []string) error {
|
||||||
|
if !s.Prev && !s.Next && s.Account == "" {
|
||||||
|
return errors.New("Usage: switch-account -n | -p | <account-name>")
|
||||||
|
}
|
||||||
|
|
||||||
|
switcher, ok := app.SelectedTabContent().(AccountSwitcher)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("this tab cannot switch accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
var acct *app.AccountView
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case s.Prev:
|
||||||
|
acct, err = app.PrevAccount()
|
||||||
|
case s.Next:
|
||||||
|
acct, err = app.NextAccount()
|
||||||
|
default:
|
||||||
|
acct, err = app.Account(s.Account)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = switcher.SwitchAccount(acct); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
acct.UpdateStatus()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChangeTab struct {
|
||||||
|
Tab string `opt:"tab" complete:"CompleteTab" desc:"Tab name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(ChangeTab{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChangeTab) Description() string {
|
||||||
|
return "Change the focus to the specified tab."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChangeTab) Context() CommandContext {
|
||||||
|
return GLOBAL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChangeTab) Aliases() []string {
|
||||||
|
return []string{"ct", "change-tab"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ChangeTab) CompleteTab(arg string) []string {
|
||||||
|
return FilterList(app.TabNames(), arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ChangeTab) Execute(args []string) error {
|
||||||
|
if c.Tab == "-" {
|
||||||
|
ok := app.SelectPreviousTab()
|
||||||
|
if !ok {
|
||||||
|
return errors.New("No previous tab to return to")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
n, err := strconv.Atoi(c.Tab)
|
||||||
|
if err == nil {
|
||||||
|
if strings.HasPrefix(c.Tab, "+") || strings.HasPrefix(c.Tab, "-") {
|
||||||
|
app.SelectTabAtOffset(n)
|
||||||
|
} else {
|
||||||
|
ok := app.SelectTabIndex(n)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("No tab with that index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ok := app.SelectTab(c.Tab)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("No tab with that name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Echo struct {
|
||||||
|
Template string `opt:"..." required:"false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(Echo{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Echo) Description() string {
|
||||||
|
return "Print text after template expansion."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Echo) Aliases() []string {
|
||||||
|
return []string{"echo"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Echo) Context() CommandContext {
|
||||||
|
return GLOBAL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Echo) Execute(args []string) error {
|
||||||
|
app.PushSuccess(e.Template)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Eml struct {
|
||||||
|
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"EML file path."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(Eml{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Eml) Description() string {
|
||||||
|
return "Open an eml file into the message viewer."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Eml) Context() CommandContext {
|
||||||
|
return GLOBAL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Eml) Aliases() []string {
|
||||||
|
return []string{"eml", "preview"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Eml) CompletePath(arg string) []string {
|
||||||
|
return CompletePath(arg, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Eml) Execute(args []string) error {
|
||||||
|
acct := app.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return fmt.Errorf("no account selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
showEml := func(r io.Reader) {
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lib.NewEmlMessageView(data, app.CryptoProvider(), app.DecryptKeys,
|
||||||
|
func(view lib.MessageView, err error) {
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgView, err := app.NewMessageViewer(acct, view)
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.NewTab(msgView,
|
||||||
|
view.MessageInfo().Envelope.Subject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Path == "" {
|
||||||
|
switch tab := app.SelectedTabContent().(type) {
|
||||||
|
case *app.MessageViewer:
|
||||||
|
part := tab.SelectedMessagePart()
|
||||||
|
tab.MessageView().FetchBodyPart(part.Index, showEml)
|
||||||
|
case *app.Composer:
|
||||||
|
var buf bytes.Buffer
|
||||||
|
h, err := tab.PrepareHeader()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tab.WriteMessage(h, &buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
showEml(&buf)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported operation")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
f, err := os.Open(xdg.ExpandHome(e.Path))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
showEml(f)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExecCmd struct {
|
||||||
|
Args []string `opt:"..."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(ExecCmd{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ExecCmd) Description() string {
|
||||||
|
return "Execute an arbitrary command in the background."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ExecCmd) Context() CommandContext {
|
||||||
|
return GLOBAL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ExecCmd) Aliases() []string {
|
||||||
|
return []string{"exec"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ExecCmd) Execute(args []string) error {
|
||||||
|
cmd := exec.Command(e.Args[0], e.Args[1:]...)
|
||||||
|
env := os.Environ()
|
||||||
|
|
||||||
|
switch view := app.SelectedTabContent().(type) {
|
||||||
|
case *app.AccountView:
|
||||||
|
env = append(env, fmt.Sprintf("account=%s", view.AccountConfig().Name))
|
||||||
|
env = append(env, fmt.Sprintf("folder=%s", view.Directories().Selected()))
|
||||||
|
case *app.MessageViewer:
|
||||||
|
acct := view.SelectedAccount()
|
||||||
|
env = append(env, fmt.Sprintf("account=%s", acct.AccountConfig().Name))
|
||||||
|
env = append(env, fmt.Sprintf("folder=%s", acct.Directories().Selected()))
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Env = env
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
} else {
|
||||||
|
if cmd.ProcessState.ExitCode() != 0 {
|
||||||
|
app.PushError(fmt.Sprintf(
|
||||||
|
"%s: completed with status %d", args[0],
|
||||||
|
cmd.ProcessState.ExitCode()))
|
||||||
|
} else {
|
||||||
|
app.PushStatus(fmt.Sprintf(
|
||||||
|
"%s: completed with status %d", args[0],
|
||||||
|
cmd.ProcessState.ExitCode()), 10*time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Help struct {
|
||||||
|
Topic string `opt:"topic" action:"ParseTopic" default:"aerc" complete:"CompleteTopic" desc:"Help topic."`
|
||||||
|
}
|
||||||
|
|
||||||
|
var pages = []string{
|
||||||
|
"aerc",
|
||||||
|
"accounts",
|
||||||
|
"binds",
|
||||||
|
"config",
|
||||||
|
"imap",
|
||||||
|
"jmap",
|
||||||
|
"notmuch",
|
||||||
|
"search",
|
||||||
|
"sendmail",
|
||||||
|
"smtp",
|
||||||
|
"stylesets",
|
||||||
|
"templates",
|
||||||
|
"tutorial",
|
||||||
|
"patch",
|
||||||
|
"keys",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(Help{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Help) Description() string {
|
||||||
|
return "Display one of aerc's man pages in the embedded terminal."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Help) Context() CommandContext {
|
||||||
|
return GLOBAL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Help) Aliases() []string {
|
||||||
|
return []string{"help", "man"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Help) CompleteTopic(arg string) []string {
|
||||||
|
return FilterList(pages, arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Help) ParseTopic(arg string) error {
|
||||||
|
for _, page := range pages {
|
||||||
|
if arg == page {
|
||||||
|
if arg != "aerc" {
|
||||||
|
arg = "aerc-" + arg
|
||||||
|
}
|
||||||
|
h.Topic = arg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unknown topic %q", arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Help) Execute(args []string) error {
|
||||||
|
if h.Topic == "aerc-keys" {
|
||||||
|
app.AddDialog(app.DefaultDialog(
|
||||||
|
app.NewListBox(
|
||||||
|
"Bindings: Press <Esc> or <Enter> to close. "+
|
||||||
|
"Start typing to filter bindings.",
|
||||||
|
app.HumanReadableBindings(),
|
||||||
|
app.SelectedAccountUiConfig(),
|
||||||
|
func(_ string) {
|
||||||
|
app.CloseDialog()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
term := Term{Cmd: []string{"man", h.Topic}}
|
||||||
|
return term.Execute(args)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cmdHistory struct {
|
||||||
|
// rolling buffer of prior commands
|
||||||
|
//
|
||||||
|
// most recent command is at the end of the list,
|
||||||
|
// least recent is index 0
|
||||||
|
cmdList []string
|
||||||
|
|
||||||
|
// current placement in list
|
||||||
|
current int
|
||||||
|
|
||||||
|
// initialize history storage
|
||||||
|
initHistfile sync.Once
|
||||||
|
histfile io.ReadWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
// number of commands to keep in history
|
||||||
|
const cmdLimit = 1000
|
||||||
|
|
||||||
|
// CmdHistory is the history of executed commands
|
||||||
|
var CmdHistory = cmdHistory{}
|
||||||
|
|
||||||
|
func (h *cmdHistory) Add(cmd string) {
|
||||||
|
h.initHistfile.Do(h.initialize)
|
||||||
|
|
||||||
|
// if we're at cap, cut off the first element
|
||||||
|
if len(h.cmdList) >= cmdLimit {
|
||||||
|
h.cmdList = h.cmdList[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(h.cmdList) == 0 || h.cmdList[len(h.cmdList)-1] != cmd {
|
||||||
|
h.cmdList = append(h.cmdList, cmd)
|
||||||
|
|
||||||
|
h.writeHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// whenever we add a new command, reset the current
|
||||||
|
// pointer to the "beginning" of the list
|
||||||
|
h.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prev returns the previous command in history.
|
||||||
|
// Since the list is reverse-order, this will return elements
|
||||||
|
// increasingly towards index 0.
|
||||||
|
func (h *cmdHistory) Prev() string {
|
||||||
|
h.initHistfile.Do(h.initialize)
|
||||||
|
|
||||||
|
if h.current <= 0 || len(h.cmdList) == 0 {
|
||||||
|
h.current = -1
|
||||||
|
return "(Already at beginning)"
|
||||||
|
}
|
||||||
|
h.current--
|
||||||
|
|
||||||
|
return h.cmdList[h.current]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next returns the next command in history.
|
||||||
|
// Since the list is reverse-order, this will return elements
|
||||||
|
// increasingly towards index len(cmdList).
|
||||||
|
func (h *cmdHistory) Next() string {
|
||||||
|
h.initHistfile.Do(h.initialize)
|
||||||
|
|
||||||
|
if h.current >= len(h.cmdList)-1 || len(h.cmdList) == 0 {
|
||||||
|
h.current = len(h.cmdList)
|
||||||
|
return "(Already at end)"
|
||||||
|
}
|
||||||
|
h.current++
|
||||||
|
|
||||||
|
return h.cmdList[h.current]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the current pointer to the beginning of history.
|
||||||
|
func (h *cmdHistory) Reset() {
|
||||||
|
h.current = len(h.cmdList)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *cmdHistory) initialize() {
|
||||||
|
var err error
|
||||||
|
openFlags := os.O_RDWR | os.O_EXCL
|
||||||
|
|
||||||
|
histPath := xdg.StatePath("aerc", "history")
|
||||||
|
if _, err := os.Stat(histPath); os.IsNotExist(err) {
|
||||||
|
_ = os.MkdirAll(xdg.StatePath("aerc"), 0o700) // caught by OpenFile
|
||||||
|
openFlags |= os.O_CREATE
|
||||||
|
}
|
||||||
|
|
||||||
|
// O_EXCL to make sure that only one aerc writes to the file
|
||||||
|
h.histfile, err = os.OpenFile(
|
||||||
|
histPath,
|
||||||
|
openFlags,
|
||||||
|
0o600,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to open history file: %v", err)
|
||||||
|
// basically mirror the old behavior
|
||||||
|
h.histfile = bytes.NewBuffer([]byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s := bufio.NewScanner(h.histfile)
|
||||||
|
|
||||||
|
for s.Scan() {
|
||||||
|
h.cmdList = append(h.cmdList, s.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *cmdHistory) writeHistory() {
|
||||||
|
if fh, ok := h.histfile.(*os.File); ok {
|
||||||
|
err := fh.Truncate(0)
|
||||||
|
if err != nil {
|
||||||
|
// if we can't delete it, don't break it.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = fh.Seek(0, io.SeekStart)
|
||||||
|
if err != nil {
|
||||||
|
// if we can't delete it, don't break it.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range h.cmdList {
|
||||||
|
fmt.Fprintln(fh, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fh.Sync() //nolint:errcheck // if your computer can't sync you're in bigger trouble
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/go-opt/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Menu struct {
|
||||||
|
ErrExit bool `opt:"-e" desc:"Stop executing commands on the first error."`
|
||||||
|
Background bool `opt:"-b" desc:"Do NOT spawn the popover dialog."`
|
||||||
|
Accounts bool `opt:"-a" desc:"Feed command with account names."`
|
||||||
|
Directories bool `opt:"-d" desc:"Feed command with folder names."`
|
||||||
|
Command string `opt:"-c" desc:"Override [general].default-menu-cmd."`
|
||||||
|
Xargs string `opt:"..." complete:"CompleteXargs" desc:"Command name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(Menu{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Menu) Description() string {
|
||||||
|
return "Open a popover dialog."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Menu) Context() CommandContext {
|
||||||
|
return GLOBAL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Menu) Aliases() []string {
|
||||||
|
return []string{"menu"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Menu) CompleteXargs(arg string) []string {
|
||||||
|
return FilterList(ActiveCommandNames(), arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Menu) Execute([]string) error {
|
||||||
|
if m.Command == "" {
|
||||||
|
m.Command = config.General.DefaultMenuCmd
|
||||||
|
}
|
||||||
|
useFallback := m.useFallback()
|
||||||
|
if m.Background && useFallback {
|
||||||
|
return errors.New("Either -c <command> or " +
|
||||||
|
"default-menu-cmd is required to run " +
|
||||||
|
"in the background.")
|
||||||
|
}
|
||||||
|
if _, _, err := ResolveCommand(m.Xargs, nil, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, err := m.feedLines()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
title := " :" + strings.TrimLeft(m.Xargs, ": \t") + " ... "
|
||||||
|
|
||||||
|
if useFallback {
|
||||||
|
return m.fallback(title, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
pick, err := os.CreateTemp("", "aerc-menu-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var proc *exec.Cmd
|
||||||
|
if strings.Contains(m.Command, "%f") {
|
||||||
|
proc = exec.Command("sh", "-c",
|
||||||
|
strings.ReplaceAll(m.Command, "%f", opt.QuoteArg(pick.Name())))
|
||||||
|
} else {
|
||||||
|
proc = exec.Command("sh", "-c", m.Command+" >&3")
|
||||||
|
proc.ExtraFiles = append(proc.ExtraFiles, pick)
|
||||||
|
}
|
||||||
|
if len(lines) > 0 {
|
||||||
|
proc.Stdin = strings.NewReader(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
xargs := func(err error) {
|
||||||
|
var buf []byte
|
||||||
|
if err == nil {
|
||||||
|
_, err = pick.Seek(0, io.SeekStart)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
buf, err = io.ReadAll(pick)
|
||||||
|
}
|
||||||
|
pick.Close()
|
||||||
|
os.Remove(pick.Name())
|
||||||
|
if err != nil {
|
||||||
|
app.PushError("command failed: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(buf) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.runCmd(string(buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Background {
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
xargs(proc.Run())
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
term, err := app.NewTerminal(proc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
term.Focus(true)
|
||||||
|
term.OnClose = func(err error) {
|
||||||
|
app.CloseDialog()
|
||||||
|
xargs(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
widget := ui.NewBox(term, title, "", app.SelectedAccountUiConfig())
|
||||||
|
app.AddDialog(app.DefaultDialog(widget))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Menu) useFallback() bool {
|
||||||
|
if m.Command == "" || m.Command == "-" {
|
||||||
|
warnMsg := "no command provided, falling back on aerc's picker."
|
||||||
|
log.Warnf(warnMsg)
|
||||||
|
app.PushWarning(warnMsg)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cmd, _, _ := strings.Cut(m.Command, " ")
|
||||||
|
_, err := exec.LookPath(cmd)
|
||||||
|
if err != nil {
|
||||||
|
warnMsg := "command '" + cmd + "' not found in PATH, " +
|
||||||
|
"falling back on aerc's picker."
|
||||||
|
log.Warnf(warnMsg)
|
||||||
|
app.PushWarning(warnMsg)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Menu) runCmd(buffer string) {
|
||||||
|
var (
|
||||||
|
cmd Command
|
||||||
|
cmdline string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, line := range strings.Split(buffer, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cmdline = m.Xargs + " " + line
|
||||||
|
cmdline, cmd, err = ResolveCommand(cmdline, nil, nil)
|
||||||
|
if err == nil {
|
||||||
|
err = ExecuteCommand(cmd, cmdline)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(m.Xargs + ": " + err.Error())
|
||||||
|
if m.ErrExit {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Menu) fallback(title string, lines []string) error {
|
||||||
|
listBox := app.NewListBox(
|
||||||
|
title, lines, app.SelectedAccountUiConfig(),
|
||||||
|
func(line string) {
|
||||||
|
app.CloseDialog()
|
||||||
|
if line == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.runCmd(line)
|
||||||
|
})
|
||||||
|
listBox.SetTextFilter(func(list []string, term string) []string {
|
||||||
|
return FilterList(list, term, func(s string) string { return s })
|
||||||
|
})
|
||||||
|
widget := ui.NewBox(listBox, "", "", app.SelectedAccountUiConfig())
|
||||||
|
app.AddDialog(app.DefaultDialog(widget))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Menu) feedLines() ([]string, error) {
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case m.Accounts && m.Directories:
|
||||||
|
for _, a := range app.AccountNames() {
|
||||||
|
account, _ := app.Account(a)
|
||||||
|
a = opt.QuoteArg(a)
|
||||||
|
for _, d := range account.Directories().List() {
|
||||||
|
dir := account.Directories().Directory(d)
|
||||||
|
if dir != nil && dir.Role != models.QueryRole {
|
||||||
|
d = opt.QuoteArg(d)
|
||||||
|
}
|
||||||
|
lines = append(lines, a+" "+d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case m.Accounts:
|
||||||
|
for _, account := range app.AccountNames() {
|
||||||
|
lines = append(lines, opt.QuoteArg(account))
|
||||||
|
}
|
||||||
|
|
||||||
|
case m.Directories:
|
||||||
|
account := app.SelectedAccount()
|
||||||
|
if account == nil {
|
||||||
|
return nil, errors.New("No account selected.")
|
||||||
|
}
|
||||||
|
for _, d := range account.Directories().List() {
|
||||||
|
dir := account.Directories().Directory(d)
|
||||||
|
if dir != nil && dir.Role != models.QueryRole {
|
||||||
|
d = opt.QuoteArg(d)
|
||||||
|
}
|
||||||
|
lines = append(lines, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package mode
|
||||||
|
|
||||||
|
import "sync/atomic"
|
||||||
|
|
||||||
|
// noquit is a counter for goroutines that requested the no-quit mode
|
||||||
|
var noquit int32
|
||||||
|
|
||||||
|
// NoQuit enters no-quit mode where aerc cannot be exited (unless the force
|
||||||
|
// option is used)
|
||||||
|
func NoQuit() {
|
||||||
|
atomic.AddInt32(&noquit, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoQuitDone leaves the no-quit mode
|
||||||
|
func NoQuitDone() {
|
||||||
|
atomic.AddInt32(&noquit, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuitAllowed checks if aerc can exit normally (only when all goroutines that
|
||||||
|
// requested a no-quit mode were done and called the NoQuitDone() function)
|
||||||
|
func QuitAllowed() bool {
|
||||||
|
return atomic.LoadInt32(&noquit) <= 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MoveTab struct {
|
||||||
|
Index int `opt:"index" metavar:"[+|-]<index>" action:"ParseIndex"`
|
||||||
|
Relative bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(MoveTab{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MoveTab) Description() string {
|
||||||
|
return "Move the selected tab to the given index."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MoveTab) Context() CommandContext {
|
||||||
|
return GLOBAL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MoveTab) ParseIndex(arg string) error {
|
||||||
|
i, err := strconv.ParseInt(arg, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Index = int(i)
|
||||||
|
if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") {
|
||||||
|
m.Relative = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MoveTab) Aliases() []string {
|
||||||
|
return []string{"move-tab"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MoveTab) Execute(args []string) error {
|
||||||
|
app.MoveTab(m.Index, m.Relative)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package msg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ARCHIVE_FLAT = "flat"
|
||||||
|
ARCHIVE_YEAR = "year"
|
||||||
|
ARCHIVE_MONTH = "month"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ARCHIVE_TYPES = []string{ARCHIVE_FLAT, ARCHIVE_YEAR, ARCHIVE_MONTH}
|
||||||
|
|
||||||
|
type Archive struct {
|
||||||
|
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
|
||||||
|
Type string `opt:"type" action:"ParseArchiveType" metavar:"flat|year|month" complete:"CompleteType" desc:"Archiving scheme."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Archive) ParseMFS(arg string) error {
|
||||||
|
if arg != "" {
|
||||||
|
mfs, ok := types.StrToStrategy[arg]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid multi-file strategy %s", arg)
|
||||||
|
}
|
||||||
|
a.MultiFileStrategy = &mfs
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Archive) ParseArchiveType(arg string) error {
|
||||||
|
for _, t := range ARCHIVE_TYPES {
|
||||||
|
if t == arg {
|
||||||
|
a.Type = arg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("invalid archive type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Archive{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Archive) Description() string {
|
||||||
|
return "Move the selected message to the archive."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Archive) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Archive) Aliases() []string {
|
||||||
|
return []string{"archive"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Archive) CompleteMFS(arg string) []string {
|
||||||
|
return commands.FilterList(types.StrategyStrs(), arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Archive) CompleteType(arg string) []string {
|
||||||
|
return commands.FilterList(ARCHIVE_TYPES, arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Archive) Execute(args []string) error {
|
||||||
|
h := newHelper()
|
||||||
|
msgs, err := h.messages()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = archive(msgs, a.MultiFileStrategy, a.Type)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func archive(msgs []*models.MessageInfo, mfs *types.MultiFileStrategy,
|
||||||
|
archiveType string,
|
||||||
|
) error {
|
||||||
|
h := newHelper()
|
||||||
|
acct, err := h.account()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store, err := h.store()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var uids []models.UID
|
||||||
|
for _, msg := range msgs {
|
||||||
|
uids = append(uids, msg.Uid)
|
||||||
|
}
|
||||||
|
archiveDir := acct.AccountConfig().Archive
|
||||||
|
marker := store.Marker()
|
||||||
|
marker.ClearVisualMark()
|
||||||
|
next := findNextNonDeleted(uids, store)
|
||||||
|
|
||||||
|
var uidMap map[string][]models.UID
|
||||||
|
switch archiveType {
|
||||||
|
case ARCHIVE_MONTH:
|
||||||
|
uidMap = groupBy(msgs, func(msg *models.MessageInfo) string {
|
||||||
|
dir := strings.Join([]string{
|
||||||
|
archiveDir,
|
||||||
|
fmt.Sprintf("%d", msg.Envelope.Date.Year()),
|
||||||
|
fmt.Sprintf("%02d", msg.Envelope.Date.Month()),
|
||||||
|
}, app.SelectedAccount().Worker().PathSeparator(),
|
||||||
|
)
|
||||||
|
return dir
|
||||||
|
})
|
||||||
|
case ARCHIVE_YEAR:
|
||||||
|
uidMap = groupBy(msgs, func(msg *models.MessageInfo) string {
|
||||||
|
dir := strings.Join([]string{
|
||||||
|
archiveDir,
|
||||||
|
fmt.Sprintf("%v", msg.Envelope.Date.Year()),
|
||||||
|
}, app.SelectedAccount().Worker().PathSeparator(),
|
||||||
|
)
|
||||||
|
return dir
|
||||||
|
})
|
||||||
|
case ARCHIVE_FLAT:
|
||||||
|
uidMap = make(map[string][]models.UID)
|
||||||
|
uidMap[archiveDir] = commands.UidsFromMessageInfos(msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(len(uidMap))
|
||||||
|
success := true
|
||||||
|
|
||||||
|
for dir, uids := range uidMap {
|
||||||
|
store.Move(uids, dir, true, mfs, func(
|
||||||
|
msg types.WorkerMessage,
|
||||||
|
) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
wg.Done()
|
||||||
|
case *types.Error:
|
||||||
|
app.PushError(msg.Error.Error())
|
||||||
|
success = false
|
||||||
|
wg.Done()
|
||||||
|
marker.Remark()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// we need to do that in the background, else we block the main thread
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
if success {
|
||||||
|
var s string
|
||||||
|
if len(uids) > 1 {
|
||||||
|
s = "%d messages archived to %s"
|
||||||
|
} else {
|
||||||
|
s = "%d message archived to %s"
|
||||||
|
}
|
||||||
|
app.PushStatus(fmt.Sprintf(s, len(uids), archiveDir), 10*time.Second)
|
||||||
|
handleDone(acct, next, store)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupBy(msgs []*models.MessageInfo,
|
||||||
|
grouper func(*models.MessageInfo) string,
|
||||||
|
) map[string][]models.UID {
|
||||||
|
m := make(map[string][]models.UID)
|
||||||
|
for _, msg := range msgs {
|
||||||
|
group := grouper(msg)
|
||||||
|
m[group] = append(m[group], msg.Uid)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package msg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-message/mail"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands/mode"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/send"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Bounce struct {
|
||||||
|
Account string `opt:"-A" complete:"CompleteAccount" desc:"Account from which to re-send the message."`
|
||||||
|
To []string `opt:"..." required:"true" complete:"CompleteTo" desc:"Recipient from address book."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Bounce{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Bounce) Description() string {
|
||||||
|
return "Re-send the selected message(s) to the specified addresses."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Bounce) Aliases() []string {
|
||||||
|
return []string{"bounce", "resend"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Bounce) CompleteAccount(arg string) []string {
|
||||||
|
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Bounce) CompleteTo(arg string) []string {
|
||||||
|
return commands.FilterList(commands.GetAddress(arg), arg, commands.QuoteSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Bounce) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Bounce) Execute(args []string) error {
|
||||||
|
if len(b.To) == 0 {
|
||||||
|
return errors.New("No recipients specified")
|
||||||
|
}
|
||||||
|
addresses := strings.Join(b.To, ", ")
|
||||||
|
|
||||||
|
app.PushStatus("Bouncing to "+addresses, 10*time.Second)
|
||||||
|
|
||||||
|
widget := app.SelectedTabContent().(app.ProvidesMessage)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
acct := widget.SelectedAccount()
|
||||||
|
if b.Account != "" {
|
||||||
|
acct, err = app.Account(b.Account)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("Failed to select account %q: %w", b.Account, err)
|
||||||
|
case acct == nil:
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
store := widget.Store()
|
||||||
|
if store == nil {
|
||||||
|
return errors.New("Cannot perform action. Messages still loading")
|
||||||
|
}
|
||||||
|
|
||||||
|
config := acct.AccountConfig()
|
||||||
|
|
||||||
|
outgoing, err := config.Outgoing.ConnectionString()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "ReadCredentials()")
|
||||||
|
}
|
||||||
|
if outgoing == "" {
|
||||||
|
return errors.New("No outgoing mail transport configured for this account")
|
||||||
|
}
|
||||||
|
uri, err := url.Parse(outgoing)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "url.Parse()")
|
||||||
|
}
|
||||||
|
|
||||||
|
rcpts, err := mail.ParseAddressList(addresses)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "ParseAddressList()")
|
||||||
|
}
|
||||||
|
|
||||||
|
var domain string
|
||||||
|
if domain_, ok := config.Params["smtp-domain"]; ok {
|
||||||
|
domain = domain_
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname, err := send.GetMessageIdHostname(config.SendWithHostname, config.From)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetMessageIdHostname()")
|
||||||
|
}
|
||||||
|
|
||||||
|
// According to RFC2822, all of the resent fields corresponding
|
||||||
|
// to a particular resending of the message SHOULD be together.
|
||||||
|
// Each new set of resent fields is prepended to the message;
|
||||||
|
// that is, the most recent set of resent fields appear earlier in the
|
||||||
|
// message.
|
||||||
|
headers := fmt.Sprintf("Resent-From: %s\r\n", config.From)
|
||||||
|
headers += "Resent-Date: %s\r\n"
|
||||||
|
headers += "Resent-Message-ID: <%s>\r\n"
|
||||||
|
headers += fmt.Sprintf("Resent-To: %s\r\n", addresses)
|
||||||
|
|
||||||
|
helper := newHelper()
|
||||||
|
uids, err := helper.markedOrSelectedUids()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
mode.NoQuit()
|
||||||
|
|
||||||
|
marker := store.Marker()
|
||||||
|
marker.ClearVisualMark()
|
||||||
|
|
||||||
|
errCh := make(chan error)
|
||||||
|
store.FetchFull(uids, func(fm *types.FullMessage) {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
|
||||||
|
var header mail.Header
|
||||||
|
var msgId string
|
||||||
|
var err, errClose error
|
||||||
|
|
||||||
|
uid := fm.Content.Uid
|
||||||
|
msg := store.Messages[uid]
|
||||||
|
if msg == nil {
|
||||||
|
errCh <- fmt.Errorf("no message info: %v", uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = header.GenerateMessageIDWithHostname(hostname); err != nil {
|
||||||
|
errCh <- errors.Wrap(err, "GenerateMessageIDWithHostname()")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgId, err = header.MessageID(); err != nil {
|
||||||
|
errCh <- errors.Wrap(err, "MessageID()")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reader := strings.NewReader(fmt.Sprintf(headers,
|
||||||
|
time.Now().Format(time.RFC1123Z), msgId))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
defer func() { errCh <- err }()
|
||||||
|
|
||||||
|
var sender io.WriteCloser
|
||||||
|
|
||||||
|
log.Debugf("Bouncing email <%s> to %s",
|
||||||
|
msg.Envelope.MessageId, addresses)
|
||||||
|
|
||||||
|
if sender, err = send.NewSender(acct.Worker(), uri,
|
||||||
|
domain, config.From, rcpts, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
errClose = sender.Close()
|
||||||
|
// If there has already been an error,
|
||||||
|
// we don't want to clobber it.
|
||||||
|
if err == nil {
|
||||||
|
err = errClose
|
||||||
|
} else if errClose != nil {
|
||||||
|
app.PushError(errClose.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if _, err = io.Copy(sender, reader); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = io.Copy(sender, fm.Content.Reader)
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
defer mode.NoQuitDone()
|
||||||
|
|
||||||
|
var total, success int
|
||||||
|
|
||||||
|
for err = range errCh {
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
} else {
|
||||||
|
success++
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
if total == len(uids) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if success != total {
|
||||||
|
marker.Remark()
|
||||||
|
app.PushError(fmt.Sprintf("Failed to bounce %d of the messages",
|
||||||
|
total-success))
|
||||||
|
} else {
|
||||||
|
plural := ""
|
||||||
|
if success > 1 {
|
||||||
|
plural = "s"
|
||||||
|
}
|
||||||
|
app.PushStatus(fmt.Sprintf("Bounced %d message%s",
|
||||||
|
success, plural), 10*time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package msg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
cryptoutil "git.sr.ht/~rjarry/aerc/lib/crypto/util"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
"github.com/emersion/go-message/mail"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Copy struct {
|
||||||
|
CreateFolders bool `opt:"-p" desc:"Create folder if it does not exist."`
|
||||||
|
Decrypt bool `opt:"-d" desc:"Decrypt the message before copying."`
|
||||||
|
Account string `opt:"-a" complete:"CompleteAccount" desc:"Copy to the specified account."`
|
||||||
|
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
|
||||||
|
Folder string `opt:"folder" complete:"CompleteFolder" desc:"Target folder."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Copy{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Copy) Description() string {
|
||||||
|
return "Copy the selected message(s) to the specified folder."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Copy) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Copy) Aliases() []string {
|
||||||
|
return []string{"cp", "copy"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Copy) ParseMFS(arg string) error {
|
||||||
|
if arg != "" {
|
||||||
|
mfs, ok := types.StrToStrategy[arg]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid multi-file strategy %s", arg)
|
||||||
|
}
|
||||||
|
c.MultiFileStrategy = &mfs
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Copy) CompleteAccount(arg string) []string {
|
||||||
|
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Copy) CompleteFolder(arg string) []string {
|
||||||
|
var acct *app.AccountView
|
||||||
|
if len(c.Account) > 0 {
|
||||||
|
acct, _ = app.Account(c.Account)
|
||||||
|
} else {
|
||||||
|
acct = app.SelectedAccount()
|
||||||
|
}
|
||||||
|
if acct == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return commands.FilterList(acct.Directories().List(), arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Copy) CompleteMFS(arg string) []string {
|
||||||
|
return commands.FilterList(types.StrategyStrs(), arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Copy) Execute(args []string) error {
|
||||||
|
h := newHelper()
|
||||||
|
uids, err := h.markedOrSelectedUids()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store, err := h.store()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// when the decrypt flag is set, add the current account to c.Account to
|
||||||
|
// ensure that we do not take the store.Copy route.
|
||||||
|
if c.Decrypt {
|
||||||
|
if acct := app.SelectedAccount(); acct != nil {
|
||||||
|
c.Account = acct.Name()
|
||||||
|
} else {
|
||||||
|
return errors.New("no account name found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.Account) == 0 {
|
||||||
|
store.Copy(uids, c.Folder, c.CreateFolders, c.MultiFileStrategy,
|
||||||
|
func(msg types.WorkerMessage) {
|
||||||
|
c.CallBack(msg, uids, store)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
destAcct, err := app.Account(c.Account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
destStore := destAcct.Store()
|
||||||
|
if destStore == nil {
|
||||||
|
app.PushError(fmt.Sprintf("No message store in %s", c.Account))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []*types.FullMessage
|
||||||
|
fetchDone := make(chan bool, 1)
|
||||||
|
store.FetchFull(uids, func(fm *types.FullMessage) {
|
||||||
|
if fm == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Decrypt {
|
||||||
|
h := new(mail.Header)
|
||||||
|
msg, ok := store.Messages[fm.Content.Uid]
|
||||||
|
if ok {
|
||||||
|
h = msg.RFC822Headers
|
||||||
|
}
|
||||||
|
cleartext, err := cryptoutil.Cleartext(fm.Content.Reader, *h)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("could not decrypt message %v", fm.Content.Uid)
|
||||||
|
} else {
|
||||||
|
fm.Content.Reader = bytes.NewReader(cleartext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, fm)
|
||||||
|
if len(messages) == len(uids) {
|
||||||
|
fetchDone <- true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Since this operation can take some time with some backends
|
||||||
|
// (e.g. IMAP), provide some feedback to inform the user that
|
||||||
|
// something is happening
|
||||||
|
app.PushStatus("Copying messages...", 10*time.Second)
|
||||||
|
go func() {
|
||||||
|
defer log.PanicHandler()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-fetchDone:
|
||||||
|
break
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
// TODO: find a better way to determine if store.FetchFull()
|
||||||
|
// has finished with some errors.
|
||||||
|
app.PushError("Failed to fetch all messages")
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, fm := range messages {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, err = buf.ReadFrom(fm.Content.Reader)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to read message: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
destStore.Append(
|
||||||
|
c.Folder,
|
||||||
|
models.SeenFlag,
|
||||||
|
time.Now(),
|
||||||
|
buf,
|
||||||
|
buf.Len(),
|
||||||
|
func(msg types.WorkerMessage) {
|
||||||
|
c.CallBack(msg, uids, store)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Copy) CallBack(msg types.WorkerMessage, uids []models.UID, store *lib.MessageStore) {
|
||||||
|
dest := c.Folder
|
||||||
|
if len(c.Account) != 0 {
|
||||||
|
dest = fmt.Sprintf("%s in %s", c.Folder, c.Account)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
var s string
|
||||||
|
if len(uids) > 1 {
|
||||||
|
s = "%d messages copied to %s"
|
||||||
|
} else {
|
||||||
|
s = "%d message copied to %s"
|
||||||
|
}
|
||||||
|
app.PushStatus(fmt.Sprintf(s, len(uids), dest), 10*time.Second)
|
||||||
|
store.Marker().ClearVisualMark()
|
||||||
|
case *types.Error:
|
||||||
|
app.PushError(msg.Error.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package msg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Delete struct {
|
||||||
|
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Delete{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Delete) Description() string {
|
||||||
|
return "Delete the selected message(s)."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Delete) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Delete) Aliases() []string {
|
||||||
|
return []string{"delete", "delete-message"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Delete) ParseMFS(arg string) error {
|
||||||
|
if arg != "" {
|
||||||
|
mfs, ok := types.StrToStrategy[arg]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid multi-file strategy %s", arg)
|
||||||
|
}
|
||||||
|
d.MultiFileStrategy = &mfs
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Delete) CompleteMFS(arg string) []string {
|
||||||
|
return commands.FilterList(types.StrategyStrs(), arg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Delete) Execute(args []string) error {
|
||||||
|
h := newHelper()
|
||||||
|
store, err := h.store()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
uids, err := h.markedOrSelectedUids()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
acct, err := h.account()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sel := store.Selected()
|
||||||
|
marker := store.Marker()
|
||||||
|
marker.ClearVisualMark()
|
||||||
|
// caution, can be nil
|
||||||
|
next := findNextNonDeleted(uids, store)
|
||||||
|
store.Delete(uids, d.MultiFileStrategy, func(msg types.WorkerMessage) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case *types.Done:
|
||||||
|
var s string
|
||||||
|
if len(uids) > 1 {
|
||||||
|
s = "%d messages deleted"
|
||||||
|
} else {
|
||||||
|
s = "%d message deleted"
|
||||||
|
}
|
||||||
|
app.PushStatus(fmt.Sprintf(s, len(uids)), 10*time.Second)
|
||||||
|
mv, isMsgView := h.msgProvider.(*app.MessageViewer)
|
||||||
|
if isMsgView {
|
||||||
|
if !config.Ui.NextMessageOnDelete {
|
||||||
|
app.RemoveTab(h.msgProvider, true)
|
||||||
|
} else {
|
||||||
|
// no more messages in the list
|
||||||
|
if next == nil {
|
||||||
|
app.RemoveTab(h.msgProvider, true)
|
||||||
|
acct.Messages().Select(-1)
|
||||||
|
ui.Invalidate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lib.NewMessageStoreView(next, mv.MessageView().SeenFlagSet(),
|
||||||
|
store, app.CryptoProvider(), app.DecryptKeys,
|
||||||
|
func(view lib.MessageView, err error) {
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextMv, err := app.NewMessageViewer(acct, view)
|
||||||
|
if err != nil {
|
||||||
|
app.PushError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.ReplaceTab(mv, nextMv, next.Envelope.Subject, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if next == nil {
|
||||||
|
// We deleted the last message, select the new last message
|
||||||
|
// instead of the first message
|
||||||
|
acct.Messages().Select(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *types.Error:
|
||||||
|
marker.Remark()
|
||||||
|
store.Select(sel.Uid)
|
||||||
|
app.PushError(msg.Error.Error())
|
||||||
|
case *types.Unsupported:
|
||||||
|
marker.Remark()
|
||||||
|
store.Select(sel.Uid)
|
||||||
|
// notmuch doesn't support it, we want the user to know
|
||||||
|
app.PushError(" error, unsupported for this worker")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findNextNonDeleted(deleted []models.UID, store *lib.MessageStore) *models.MessageInfo {
|
||||||
|
var next, previous *models.MessageInfo
|
||||||
|
stepper := []func(){store.Next, store.Prev}
|
||||||
|
for _, stepFn := range stepper {
|
||||||
|
previous = nil
|
||||||
|
for {
|
||||||
|
next = store.Selected()
|
||||||
|
if next != nil && !contains(deleted, next.Uid) {
|
||||||
|
if _, deleted := store.Deleted[next.Uid]; !deleted {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next == nil || previous == next {
|
||||||
|
// If previous == next, this is the last
|
||||||
|
// message. Set next to nil either way
|
||||||
|
next = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
stepFn()
|
||||||
|
previous = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if next != nil {
|
||||||
|
store.Select(next.Uid)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(uids []models.UID, uid models.UID) bool {
|
||||||
|
for _, item := range uids {
|
||||||
|
if item == uid {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package msg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/app"
|
||||||
|
"git.sr.ht/~rjarry/aerc/commands"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/format"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||||
|
"git.sr.ht/~rjarry/aerc/models"
|
||||||
|
"github.com/emersion/go-message/mail"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Envelope struct {
|
||||||
|
Header bool `opt:"-h" desc:"Show all header fields."`
|
||||||
|
Format string `opt:"-s" default:"%-20.20s: %s" desc:"Format specifier."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands.Register(Envelope{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Envelope) Description() string {
|
||||||
|
return "Open the message envelope in a dialog popup."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Envelope) Context() commands.CommandContext {
|
||||||
|
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Envelope) Aliases() []string {
|
||||||
|
return []string{"envelope"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Envelope) Execute(args []string) error {
|
||||||
|
provider, ok := app.SelectedTabContent().(app.ProvidesMessages)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("current tab does not implement app.ProvidesMessage interface")
|
||||||
|
}
|
||||||
|
|
||||||
|
acct := provider.SelectedAccount()
|
||||||
|
if acct == nil {
|
||||||
|
return errors.New("No account selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []string
|
||||||
|
if msg, err := provider.SelectedMessage(); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
if msg != nil {
|
||||||
|
if e.Header {
|
||||||
|
list = parseHeader(msg, e.Format)
|
||||||
|
} else {
|
||||||
|
list = parseEnvelope(msg, e.Format,
|
||||||
|
acct.UiConfig().TimestampFormat)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("Selected message is empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.AddDialog(app.DefaultDialog(
|
||||||
|
app.NewListBox(
|
||||||
|
"Message Envelope. Press <Esc> or <Enter> to close. "+
|
||||||
|
"Start typing to filter.",
|
||||||
|
list,
|
||||||
|
app.SelectedAccountUiConfig(),
|
||||||
|
func(_ string) {
|
||||||
|
app.CloseDialog()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEnvelope(msg *models.MessageInfo, fmtStr, fmtTime string,
|
||||||
|
) (result []string) {
|
||||||
|
if envlp := msg.Envelope; envlp != nil {
|
||||||
|
addStr := func(key, text string) {
|
||||||
|
result = append(result, fmt.Sprintf(fmtStr, key, text))
|
||||||
|
}
|
||||||
|
addAddr := func(key string, ls []*mail.Address) {
|
||||||
|
for _, l := range ls {
|
||||||
|
result = append(result,
|
||||||
|
fmt.Sprintf(fmtStr, key,
|
||||||
|
format.AddressForHumans(l)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addStr("Date", envlp.Date.Format(fmtTime))
|
||||||
|
addStr("Subject", envlp.Subject)
|
||||||
|
addStr("Message-Id", envlp.MessageId)
|
||||||
|
|
||||||
|
addAddr("From", envlp.From)
|
||||||
|
addAddr("To", envlp.To)
|
||||||
|
addAddr("ReplyTo", envlp.ReplyTo)
|
||||||
|
addAddr("Cc", envlp.Cc)
|
||||||
|
addAddr("Bcc", envlp.Bcc)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHeader(msg *models.MessageInfo, fmtStr string) (result []string) {
|
||||||
|
if h := msg.RFC822Headers; h != nil {
|
||||||
|
hf := h.Fields()
|
||||||
|
for hf.Next() {
|
||||||
|
text, err := hf.Text()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(err.Error())
|
||||||
|
text = hf.Value()
|
||||||
|
}
|
||||||
|
result = append(result,
|
||||||
|
headerExpand(fmtStr, hf.Key(), text)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func headerExpand(fmtStr, key, text string) []string {
|
||||||
|
var result []string
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "to", "from", "bcc", "cc":
|
||||||
|
for _, item := range strings.Split(text, ",") {
|
||||||
|
result = append(result, fmt.Sprintf(fmtStr, key,
|
||||||
|
strings.TrimSpace(item)))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
result = append(result, fmt.Sprintf(fmtStr, key, text))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user