mirror of
https://git.sr.ht/~rjarry/aerc
synced 2025-02-22 23:23:57 +01:00

In addition of expanding collapsing the currently selected folder with no argument, allow specifying a folder name. Changelog-added: `:expand-folder` and `:collapse-folder` can now act on a non selected folder. Suggested-by: Drew Devault <sir@cmpwn.com> Signed-off-by: Robin Jarry <robin@jarry.cc> Tested-by: Inwit <inwit@sindominio.net>
543 lines
12 KiB
Go
543 lines
12 KiB
Go
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 ""
|
|
}
|