1
0
Fork 0
mirror of https://git.sr.ht/~rjarry/aerc synced 2025-07-12 03:00:21 +02:00
aerc/commands/util.go
Robin Jarry 3023b3a4e5 pipe: fix deadlock when piping large messages to less
We have the ability to pipe data to a QuickTerm via its stdin argument.
This function creates a virtual terminal and uses its OnStart callback
to read from stdin and write it to the created command pipe.

The OnStart callback is invoked in app.Terminal.Draw but in that case,
it waits until all data has been read and copied before returning which
prevents the main thread from actually rendering less displaying that
data on screen which blocks everything.

Keep the data copying completely async and out of the main thread to
avoid dead locking.

Reported-by: Simon Martin <simon@nasilyan.com>
Signed-off-by: Robin Jarry <robin@jarry.cc>
Tested-by: Simon Martin <simon@nasilyan.com>
2025-06-03 08:55:43 +02:00

291 lines
6.5 KiB
Go

package commands
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/lithammer/fuzzysearch/fuzzy"
"git.sr.ht/~rjarry/aerc/app"
"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"
"git.sr.ht/~rjarry/aerc/worker/types"
"git.sr.ht/~rjarry/go-opt/v2"
"git.sr.ht/~rockorager/vaxis"
)
// QuickTerm is an ephemeral terminal for running a single command and quitting.
func QuickTerm(args []string, stdin io.Reader, silent bool) (*app.Terminal, error) {
cmd := exec.Command(args[0], args[1:]...)
pipe, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
term, err := app.NewTerminal(cmd)
if err != nil {
return nil, err
}
term.OnClose = func(err error) {
if err != nil {
app.PushError(err.Error())
// remove the tab on error, otherwise it gets stuck
app.RemoveTab(term, false)
return
}
if silent {
app.RemoveTab(term, true)
} else {
app.PushStatus("Process complete, press any key to close.",
10*time.Second)
term.OnEvent = func(event vaxis.Event) bool {
app.RemoveTab(term, true)
return true
}
}
}
term.OnStart = func() {
go func() {
defer log.PanicHandler()
defer pipe.Close()
if _, err := io.Copy(pipe, stdin); err != nil {
app.PushError(err.Error())
}
}()
}
return term, nil
}
// CompletePath provides filesystem completions given a starting path.
func CompletePath(path string, onlyDirs bool) []string {
return completePath(path, onlyDirs, app.SelectedAccountUiConfig().FuzzyComplete)
}
func completePath(path string, onlyDirs bool, fuzzyComplete bool) []string {
if path == ".." || strings.HasSuffix(path, "/..") {
return []string{path + "/"}
}
if path == "~" {
path = xdg.HomeDir() + "/"
} else if strings.HasPrefix(path, "~/") {
path = xdg.HomeDir() + strings.TrimPrefix(path, "~")
}
includeHidden := path == "."
if i := strings.LastIndex(path, "/"); i != -1 && i < len(path)-1 {
includeHidden = strings.HasPrefix(path[i+1:], ".")
}
const currentDir = "."
dir, search := filepath.Split(path)
// for `file` case dir will be `` which does not work in listDir
if dir == "" {
dir = currentDir
}
entries := listDir(dir, includeHidden)
filteredEntries := make([]string, 0, len(entries))
for _, m := range entries {
testM := m
if dir != currentDir {
testM = dir + m
}
if isDir(testM) {
m += "/"
} else if onlyDirs {
continue
}
filteredEntries = append(filteredEntries, m)
}
results := filterList(
filteredEntries,
search,
func(s string) string {
if dir != currentDir {
s = dir + s
}
return opt.QuoteArg(xdg.TildeHome(s))
},
fuzzyComplete,
)
sort.Strings(results)
return results
}
func isDir(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return info.IsDir()
}
// return all filenames in a directory, optionally including hidden files
func listDir(path string, hidden bool) []string {
f, err := os.Open(path)
if err != nil {
return []string{}
}
files, err := f.Readdirnames(-1) // read all dir names
if err != nil {
return []string{}
}
if hidden {
return files
}
var filtered []string
for _, g := range files {
if !strings.HasPrefix(g, ".") {
filtered = append(filtered, g)
}
}
return filtered
}
// MarkedOrSelected returns either all marked messages if any are marked or the
// selected message instead
func MarkedOrSelected(pm app.ProvidesMessages) ([]models.UID, error) {
// marked has priority over the selected message
marked, err := pm.MarkedMessages()
if err != nil {
return nil, err
}
if len(marked) > 0 {
marked = expandFoldedThreads(pm, marked)
return marked, nil
}
msg, err := pm.SelectedMessage()
if err != nil {
return nil, err
}
return expandFoldedThreads(pm, []models.UID{msg.Uid}), nil
}
func expandFoldedThreads(pm app.ProvidesMessages, uids []models.UID) []models.UID {
store := pm.Store()
if store == nil {
return uids
}
expanded := make([]models.UID, len(uids))
copy(expanded, uids)
for _, uid := range uids {
thread, err := store.Thread(uid)
if err != nil {
continue
}
if thread != nil && thread.FirstChild != nil && thread.FirstChild.Hidden > 0 {
_ = thread.Walk(func(t *types.Thread, _ int, __ error) error {
if t.Uid != uid {
expanded = append(expanded, t.Uid)
}
return nil
})
}
}
if len(uids) != len(expanded) {
log.Debugf("expand folded threads: %v -> %v\n", uids, expanded)
}
return expanded
}
// UidsFromMessageInfos extracts a uid slice from a slice of MessageInfos
func UidsFromMessageInfos(msgs []*models.MessageInfo) []models.UID {
uids := make([]models.UID, len(msgs))
i := 0
for _, msg := range msgs {
uids[i] = msg.Uid
i++
}
return uids
}
func MsgInfoFromUids(store *lib.MessageStore, uids []models.UID, statusInfo func(string)) ([]*models.MessageInfo, error) {
infos := make([]*models.MessageInfo, len(uids))
needHeaders := make([]models.UID, 0)
for i, uid := range uids {
var ok bool
infos[i], ok = store.Messages[uid]
if !ok {
return nil, fmt.Errorf("uid not found")
}
if infos[i] == nil {
needHeaders = append(needHeaders, uid)
}
}
if len(needHeaders) > 0 {
store.FetchHeaders(needHeaders, func(msg types.WorkerMessage) {
var info string
switch m := msg.(type) {
case *types.Done:
info = "All headers fetched. Please repeat command."
case *types.Error:
info = fmt.Sprintf("Encountered error while fetching headers: %v", m.Error)
}
if statusInfo != nil {
statusInfo(info)
}
})
return nil, fmt.Errorf("Fetching missing message headers. Please wait.")
}
return infos, nil
}
func QuoteSpace(s string) string {
return opt.QuoteArg(s) + " "
}
// FilterList takes a list of valid completions and filters it, either
// by case smart prefix, or by fuzzy matching
// An optional post processing function can be passed to prepend, append or
// quote each value.
func FilterList(
valid []string,
search string,
postProc func(string) string,
) []string {
return filterList(valid, search, postProc, app.SelectedAccountUiConfig().FuzzyComplete)
}
func filterList(
valid []string,
search string,
postProc func(string) string,
fuzzyComplete bool,
) []string {
if postProc == nil {
postProc = opt.QuoteArg
}
out := make([]string, 0, len(valid))
if fuzzyComplete {
for _, v := range fuzzy.RankFindFold(search, valid) {
out = append(out, postProc(v.Target))
}
} else {
for _, v := range valid {
if hasCaseSmartPrefix(v, search) {
out = append(out, postProc(v))
}
}
}
return out
}