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/xdg" "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/worker/types" "git.sr.ht/~rjarry/go-opt" "github.com/gdamore/tcell/v2" ) // QuickTerm is an ephemeral terminal for running a single command and quitting. func QuickTerm(args []string, stdin io.Reader) (*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) } else { app.PushStatus("Process complete, press any key to close.", 10*time.Second) term.OnEvent = func(event tcell.Event) bool { app.RemoveTab(term, true) return true } } } term.OnStart = func() { status := make(chan error, 1) go func() { defer log.PanicHandler() _, err := io.Copy(pipe, stdin) defer pipe.Close() status <- err }() err := <-status if err != nil { app.PushError(err.Error()) } } return term, nil } // CompletePath provides filesystem completions given a starting path. func CompletePath(path string) []string { if path == "" { // default to cwd cwd, err := os.Getwd() if err != nil { return nil } path = cwd } // strip trailing slashes, etc. path = filepath.Clean(xdg.ExpandHome(path)) if _, err := os.Stat(path); os.IsNotExist(err) { // if the path doesn't exist, it is likely due to it being a partial path // in this case, we want to return possible matches (ie /hom* should match // /home) matches, err := filepath.Glob(fmt.Sprintf("%s*", path)) if err != nil { return nil } if !strings.HasPrefix(path, ".") && !strings.Contains(path, "/.") { log.Tracef("removing hidden files from glob results") for i := len(matches) - 1; i >= 0; i-- { if strings.HasPrefix(filepath.Base(matches[i]), ".") { if i == len(matches)-1 { matches = matches[:i] continue } matches = append(matches[:i], matches[i+1:]...) } } } for i, m := range matches { if isDir(m) { m += "/" } matches[i] = opt.QuoteArg((xdg.TildeHome(m))) } sort.Strings(matches) return matches } files := listDir(path, false) for i, f := range files { f = filepath.Join(path, f) if isDir(f) { f += "/" } files[i] = opt.QuoteArg((xdg.TildeHome(f))) } sort.Strings(files) return files } 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) ([]uint32, 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, []uint32{msg.Uid}), nil } func expandFoldedThreads(pm app.ProvidesMessages, uids []uint32) []uint32 { store := pm.Store() if store == nil { return uids } expanded := make([]uint32, 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) []uint32 { uids := make([]uint32, len(msgs)) i := 0 for _, msg := range msgs { uids[i] = msg.Uid i++ } return uids } func MsgInfoFromUids(store *lib.MessageStore, uids []uint32, statusInfo func(string)) ([]*models.MessageInfo, error) { infos := make([]*models.MessageInfo, len(uids)) needHeaders := make([]uint32, 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 { if postProc == nil { postProc = opt.QuoteArg } out := make([]string, 0, len(valid)) if app.SelectedAccountUiConfig().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 }