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