Compare commits

...

5 commits

Author SHA1 Message Date
Robin Jarry
14ceca3200 address-book-cmd: be more lenient with errors
Instead of failing completely when address-book-cmd returns an invalid
line, ignore the line and report a warning in the logs.

Do not wait for 100 "valid" addresses before bailing out as the command
could only output garbage in large quantities. The issue of the command
not printing any new line characters still exists. We could address this
but I think it would be overkill.

Reported-by: Bence Ferdinandy <bence@ferdinandy.com>
Signed-off-by: Robin Jarry <robin@jarry.cc>
Tested-by: Bence Ferdinandy <bence@ferdinandy.com>
2022-11-03 10:15:12 +01:00
Robin Jarry
7565a96525 address-book-cmd: ignore completion above 100 items
Avoid aerc from consuming all memory on the system if an address book
command returns 12 million addresses.

Read at most the first 100 lines and kill the command if it has not
finished. Display a warning in the logs for good measure.

The command is now assigned an different PGID (equal to its PID) to
allow killing it *and* all of its children. When the address book
command is a shell script that forks a process which never exits, it
will avoid killing the shell process and leaving its children without
parents.

Signed-off-by: Robin Jarry <robin@jarry.cc>
Tested-by: Bence Ferdinandy <bence@ferdinandy.com>
2022-11-02 13:18:19 +01:00
Koni Marti
ae99f4c5bb msglist: display reversed thread ordering
Reverse the ordering of the message threads.

Implements: https://todo.sr.ht/~rjarry/aerc/54
Signed-off-by: Koni Marti <koni.marti@gmail.com>
Acked-by: Robin Jarry <robin@jarry.cc>
2022-10-27 22:47:30 +02:00
Koni Marti
006e10357b threads: reverse thread ordering
Add reverse-thread-order option to the ui config to enable reverse
display of the mesage threads. Default order is the the intial message
is on the top with all the replies being displayed below. The reverse
options will put the initial message at the bottom with the replies on
top.

Signed-off-by: Koni Marti <koni.marti@gmail.com>
Acked-by: Robin Jarry <robin@jarry.cc>
2022-10-27 22:44:39 +02:00
Koni Marti
88afe7bb4a store: implement the simplified index handling
Simplify the index handling for moving to the next or previous message.
Same for moving to the next or previous search results. Moving of the
index variable relies on the iterator package's StartIndex and EndIndex
functions.

Signed-off-by: Koni Marti <koni.marti@gmail.com>
Acked-by: Robin Jarry <robin@jarry.cc>
2022-10-27 22:44:39 +02:00
11 changed files with 179 additions and 104 deletions

View file

@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
`:rmdir`, `:archive` and the `copy-to` option. `:rmdir`, `:archive` and the `copy-to` option.
- Display messages from bottom to top with `reverse-msglist-order=true` in - Display messages from bottom to top with `reverse-msglist-order=true` in
`aerc.conf`. `aerc.conf`.
- Display threads from bottom to top with `reverse-thread-order=true` in
`aerc.conf`.
### Fixed ### Fixed

View file

@ -10,7 +10,9 @@ import (
"os/exec" "os/exec"
"regexp" "regexp"
"strings" "strings"
"syscall"
"git.sr.ht/~rjarry/aerc/logging"
"github.com/google/shlex" "github.com/google/shlex"
) )
@ -71,6 +73,10 @@ func isAddressHeader(h string) bool {
return false return false
} }
const maxCompletionLines = 100
var tooManyLines = fmt.Errorf("returned more than %d lines", maxCompletionLines)
// completeAddress uses the configured address book completion command to fetch // completeAddress uses the configured address book completion command to fetch
// completions for the specified string, returning a slice of completions and // completions for the specified string, returning a slice of completions and
// a prefix to be prepended to the selected completion, or an error. // a prefix to be prepended to the selected completion, or an error.
@ -88,6 +94,8 @@ func (c *Completer) completeAddress(s string) ([]string, string, error) {
if err != nil { if err != nil {
return nil, "", fmt.Errorf("stderr: %w", err) return nil, "", fmt.Errorf("stderr: %w", err)
} }
// reset the process group id to allow killing all its children
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return nil, "", fmt.Errorf("cmd start: %w", err) return nil, "", fmt.Errorf("cmd start: %w", err)
} }
@ -99,6 +107,12 @@ func (c *Completer) completeAddress(s string) ([]string, string, error) {
completions, err := readCompletions(stdout) completions, err := readCompletions(stdout)
if err != nil { if err != nil {
// make sure to kill the process *and* all its children
//nolint:errcheck // who cares?
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
logging.Warnf("command %s killed: %s", cmd, err)
}
if err != nil && !errors.Is(err, tooManyLines) {
buf, _ := io.ReadAll(stderr) buf, _ := io.ReadAll(stderr)
msg := strings.TrimSpace(string(buf)) msg := strings.TrimSpace(string(buf))
if msg != "" { if msg != "" {
@ -148,27 +162,38 @@ func (c *Completer) getAddressCmd(s string) (*exec.Cmd, error) {
func readCompletions(r io.Reader) ([]string, error) { func readCompletions(r io.Reader) ([]string, error) {
buf := bufio.NewReader(r) buf := bufio.NewReader(r)
completions := []string{} completions := []string{}
for { for i := 0; i < maxCompletionLines; i++ {
line, err := buf.ReadString('\n') line, err := buf.ReadString('\n')
if errors.Is(err, io.EOF) { if errors.Is(err, io.EOF) {
return completions, nil return completions, nil
} else if err != nil { } else if err != nil {
return nil, err return nil, err
} }
if strings.TrimSpace(line) == "" {
// skip empty lines
continue
}
parts := strings.SplitN(line, "\t", 3) parts := strings.SplitN(line, "\t", 3)
addr, err := mail.ParseAddress(strings.TrimSpace(parts[0])) addr, err := mail.ParseAddress(strings.TrimSpace(parts[0]))
if err != nil { if err != nil {
return nil, err logging.Warnf(
"line %d: %#v: could not parse address: %v",
line, err)
continue
} }
if len(parts) > 1 { if len(parts) > 1 {
addr.Name = strings.TrimSpace(parts[1]) addr.Name = strings.TrimSpace(parts[1])
} }
decoded, err := decodeMIME(addr.String()) decoded, err := decodeMIME(addr.String())
if err != nil { if err != nil {
return nil, fmt.Errorf("could not decode MIME string: %w", err) logging.Warnf(
"line %d: %#v: could not decode MIME string: %v",
i+1, line, err)
continue
} }
completions = append(completions, decoded) completions = append(completions, decoded)
} }
return completions, tooManyLines
} }
func decodeMIME(s string) (string, error) { func decodeMIME(s string) (string, error) {

View file

@ -194,6 +194,14 @@ completion-popovers=true
# Default: false # Default: false
#reverse-msglist-order = false #reverse-msglist-order = false
# Reverse display of the mesage threads. Default order is the the intial
# message is on the top with all the replies being displayed below. The
# reverse option will put the initial message at the bottom with the
# replies on top.
#
# Default: false
#reverse-thread-order=false
#[ui:account=foo] #[ui:account=foo]
# #
# Enable a threaded view of messages. If this is not supported by the backend # Enable a threaded view of messages. If this is not supported by the backend

View file

@ -79,7 +79,8 @@ type UIConfig struct {
BorderCharVertical rune `ini:"-"` BorderCharVertical rune `ini:"-"`
BorderCharHorizontal rune `ini:"-"` BorderCharHorizontal rune `ini:"-"`
ReverseOrder bool `ini:"reverse-msglist-order"` ReverseOrder bool `ini:"reverse-msglist-order"`
ReverseThreadOrder bool `ini:"reverse-thread-order"`
} }
type ContextType int type ContextType int

View file

@ -335,6 +335,14 @@ These options are configured in the *[ui]* section of aerc.conf.
Default: false Default: false
*reverse-thread-order*
Reverse display of the mesage threads. Default order is the the intial
message is on the top with all the replies being displayed below. The
reverse option will put the initial message at the bottom with the
replies on top.
Default: false
*threading-enabled* *threading-enabled*
Enable a threaded view of messages. If this is not supported by the Enable a threaded view of messages. If this is not supported by the
backend (IMAP server or notmuch), threads will be built by the client. backend (IMAP server or notmuch), threads will be built by the client.

View file

@ -21,6 +21,9 @@ func FixBounds(i, lower, upper int) int {
// WrapBounds will wrap the index i around its upper- or lower-bound if // WrapBounds will wrap the index i around its upper- or lower-bound if
// out-of-bound // out-of-bound
func WrapBounds(i, lower, upper int) int { func WrapBounds(i, lower, upper int) int {
if upper <= 0 {
return lower
}
switch { switch {
case i > upper: case i > upper:
i = lower + (i-upper-1)%upper i = lower + (i-upper-1)%upper

View file

@ -39,9 +39,10 @@ type MessageStore struct {
sortCriteria []*types.SortCriterion sortCriteria []*types.SortCriterion
threadedView bool threadedView bool
buildThreads bool reverseThreadOrder bool
builder *ThreadBuilder buildThreads bool
builder *ThreadBuilder
// Map of uids we've asked the worker to fetch // Map of uids we've asked the worker to fetch
onUpdate func(store *MessageStore) // TODO: multiple onUpdate handlers onUpdate func(store *MessageStore) // TODO: multiple onUpdate handlers
@ -74,7 +75,7 @@ func NewMessageStore(worker *types.Worker,
dirInfo *models.DirectoryInfo, dirInfo *models.DirectoryInfo,
defaultSortCriteria []*types.SortCriterion, defaultSortCriteria []*types.SortCriterion,
thread bool, clientThreads bool, clientThreadsDelay time.Duration, thread bool, clientThreads bool, clientThreadsDelay time.Duration,
reverseOrder bool, reverseOrder bool, reverseThreadOrder bool,
triggerNewEmail func(*models.MessageInfo), triggerNewEmail func(*models.MessageInfo),
triggerDirectoryChange func(), triggerDirectoryChange func(),
) *MessageStore { ) *MessageStore {
@ -91,8 +92,9 @@ func NewMessageStore(worker *types.Worker,
bodyCallbacks: make(map[uint32][]func(*types.FullMessage)), bodyCallbacks: make(map[uint32][]func(*types.FullMessage)),
threadedView: thread, threadedView: thread,
buildThreads: clientThreads, buildThreads: clientThreads,
reverseThreadOrder: reverseThreadOrder,
filter: []string{"filter"}, filter: []string{"filter"},
sortCriteria: defaultSortCriteria, sortCriteria: defaultSortCriteria,
@ -231,7 +233,7 @@ func (store *MessageStore) Update(msg types.WorkerMessage) {
newMap := make(map[uint32]*models.MessageInfo) newMap := make(map[uint32]*models.MessageInfo)
builder := NewThreadBuilder(store.iterFactory) builder := NewThreadBuilder(store.iterFactory)
builder.RebuildUids(msg.Threads) builder.RebuildUids(msg.Threads, store.reverseThreadOrder)
store.uids = builder.Uids() store.uids = builder.Uids()
store.threads = msg.Threads store.threads = msg.Threads
@ -364,6 +366,14 @@ func (store *MessageStore) update(threads bool) {
} }
} }
func (store *MessageStore) SetReverseThreadOrder(reverse bool) {
store.reverseThreadOrder = reverse
}
func (store *MessageStore) ReverseThreadOrder() bool {
return store.reverseThreadOrder
}
func (store *MessageStore) SetThreadedView(thread bool) { func (store *MessageStore) SetThreadedView(thread bool) {
store.threadedView = thread store.threadedView = thread
if store.buildThreads { if store.buildThreads {
@ -424,7 +434,7 @@ func (store *MessageStore) runThreadBuilderNow() {
} }
} }
// build new threads // build new threads
th := store.builder.Threads(store.uids) th := store.builder.Threads(store.uids, store.reverseThreadOrder)
// save local threads to the message store variable and // save local threads to the message store variable and
// run callback if defined (callback should reposition cursor) // run callback if defined (callback should reposition cursor)
@ -593,29 +603,20 @@ func (store *MessageStore) NextPrev(delta int) {
if len(uids) == 0 { if len(uids) == 0 {
return return
} }
iter := store.iterFactory.NewIterator(uids) iter := store.iterFactory.NewIterator(uids)
uid := store.SelectedUid() newIdx := store.FindIndexByUid(store.SelectedUid())
newIdx := store.FindIndexByUid(uid)
if newIdx < 0 { if newIdx < 0 {
store.Select(uids[iter.StartIndex()]) store.Select(uids[iter.StartIndex()])
return return
} }
newIdx = iterator.MoveIndex(
low, high := iter.EndIndex(), iter.StartIndex() newIdx,
sign := -1 delta,
if high < low { iter,
low, high = high, low iterator.FixBounds,
sign = 1 )
}
newIdx += sign * delta
if newIdx >= len(uids) {
newIdx = high
} else if newIdx < 0 {
newIdx = low
}
store.Select(uids[newIdx]) store.Select(uids[newIdx])
if store.BuildThreads() && store.ThreadedView() { if store.BuildThreads() && store.ThreadedView() {
@ -631,15 +632,7 @@ func (store *MessageStore) NextPrev(delta int) {
if store.marker != nil { if store.marker != nil {
store.marker.UpdateVisualMark() store.marker.UpdateVisualMark()
} }
store.updateResults()
nextResultIndex := len(store.results) - store.resultIndex - 2*delta
if nextResultIndex < 0 || nextResultIndex >= len(store.results) {
return
}
nextResultUid := store.results[nextResultIndex]
if nextResultUid == store.SelectedUid() {
store.resultIndex += delta
}
} }
func (store *MessageStore) Next() { func (store *MessageStore) Next() {
@ -690,18 +683,35 @@ func (store *MessageStore) ApplyClear() {
store.Sort(nil, nil) store.Sort(nil, nil)
} }
func (store *MessageStore) updateResults() {
if len(store.results) == 0 || store.resultIndex < 0 {
return
}
uid := store.SelectedUid()
for i, u := range store.results {
if uid == u {
store.resultIndex = i
break
}
}
}
func (store *MessageStore) nextPrevResult(delta int) { func (store *MessageStore) nextPrevResult(delta int) {
if len(store.results) == 0 { if len(store.results) == 0 {
return return
} }
store.resultIndex += delta iter := store.iterFactory.NewIterator(store.results)
if store.resultIndex >= len(store.results) {
store.resultIndex = 0
}
if store.resultIndex < 0 { if store.resultIndex < 0 {
store.resultIndex = len(store.results) - 1 store.resultIndex = iter.StartIndex()
} else {
store.resultIndex = iterator.MoveIndex(
store.resultIndex,
delta,
iter,
iterator.WrapBounds,
)
} }
store.Select(store.results[len(store.results)-store.resultIndex-1]) store.Select(store.results[store.resultIndex])
store.update(false) store.update(false)
} }

View file

@ -55,7 +55,7 @@ func (builder *ThreadBuilder) Update(msg *models.MessageInfo) {
} }
// Threads returns a slice of threads for the given list of uids // Threads returns a slice of threads for the given list of uids
func (builder *ThreadBuilder) Threads(uids []uint32) []*types.Thread { func (builder *ThreadBuilder) Threads(uids []uint32, inverse bool) []*types.Thread {
builder.Lock() builder.Lock()
defer builder.Unlock() defer builder.Unlock()
@ -67,7 +67,7 @@ func (builder *ThreadBuilder) Threads(uids []uint32) []*types.Thread {
builder.sortThreads(threads, uids) builder.sortThreads(threads, uids)
// rebuild uids from threads // rebuild uids from threads
builder.RebuildUids(threads) builder.RebuildUids(threads, inverse)
elapsed := time.Since(start) elapsed := time.Since(start)
logging.Infof("%d threads created in %s", len(threads), elapsed) logging.Infof("%d threads created in %s", len(threads), elapsed)
@ -155,15 +155,23 @@ func (builder *ThreadBuilder) sortThreads(threads []*types.Thread, orderedUids [
} }
// RebuildUids rebuilds the uids from the given slice of threads // RebuildUids rebuilds the uids from the given slice of threads
func (builder *ThreadBuilder) RebuildUids(threads []*types.Thread) { func (builder *ThreadBuilder) RebuildUids(threads []*types.Thread, inverse bool) {
uids := make([]uint32, 0, len(threads)) uids := make([]uint32, 0, len(threads))
iterT := builder.iterFactory.NewIterator(threads) iterT := builder.iterFactory.NewIterator(threads)
for iterT.Next() { for iterT.Next() {
var threaduids []uint32
_ = iterT.Value().(*types.Thread).Walk( _ = iterT.Value().(*types.Thread).Walk(
func(t *types.Thread, level int, currentErr error) error { func(t *types.Thread, level int, currentErr error) error {
uids = append(uids, t.Uid) threaduids = append(threaduids, t.Uid)
return nil return nil
}) })
if inverse {
for j := len(threaduids) - 1; j >= 0; j-- {
uids = append(uids, threaduids[j])
}
} else {
uids = append(uids, threaduids...)
}
} }
result := make([]uint32, 0, len(uids)) result := make([]uint32, 0, len(uids))
iterU := builder.iterFactory.NewIterator(uids) iterU := builder.iterFactory.NewIterator(uids)

View file

@ -286,6 +286,7 @@ func (acct *AccountView) onMessage(msg types.WorkerMessage) {
acct.dirlist.UiConfig(name).ForceClientThreads, acct.dirlist.UiConfig(name).ForceClientThreads,
acct.dirlist.UiConfig(name).ClientThreadsDelay, acct.dirlist.UiConfig(name).ClientThreadsDelay,
acct.dirlist.UiConfig(name).ReverseOrder, acct.dirlist.UiConfig(name).ReverseOrder,
acct.dirlist.UiConfig(name).ReverseThreadOrder,
func(msg *models.MessageInfo) { func(msg *models.MessageInfo) {
acct.conf.Triggers.ExecNewEmail(acct.acct, acct.conf.Triggers.ExecNewEmail(acct.acct,
acct.conf, msg) acct.conf, msg)

View file

@ -249,7 +249,7 @@ func (dt *DirectoryTree) countVisible(list []*types.Thread) (n int) {
func (dt *DirectoryTree) displayText(node *types.Thread) string { func (dt *DirectoryTree) displayText(node *types.Thread) string {
elems := strings.Split(dt.treeDirs[getAnyUid(node)], dt.pathSeparator) elems := strings.Split(dt.treeDirs[getAnyUid(node)], dt.pathSeparator)
return fmt.Sprintf("%s%s%s", threadPrefix(node), getFlag(node), elems[countLevels(node)]) return fmt.Sprintf("%s%s%s", threadPrefix(node, false), getFlag(node), elems[countLevels(node)])
} }
func (dt *DirectoryTree) getDirectory(node *types.Thread) string { func (dt *DirectoryTree) getDirectory(node *types.Thread) string {

View file

@ -12,6 +12,7 @@ import (
"git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/format" "git.sr.ht/~rjarry/aerc/lib/format"
"git.sr.ht/~rjarry/aerc/lib/iterator"
"git.sr.ht/~rjarry/aerc/lib/ui" "git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/logging" "git.sr.ht/~rjarry/aerc/logging"
"git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/models"
@ -88,57 +89,61 @@ func (ml *MessageList) Draw(ctx *ui.Context) {
row int = 0 row int = 0
) )
if store.ThreadedView() { createBaseCtx := func(uid uint32, row int) format.Ctx {
iter := store.ThreadsIterator() return format.Ctx{
var i int = 0 FromAddress: acct.acct.From,
AccountName: acct.Name(),
MsgInfo: store.Messages[uid],
MsgNum: row,
MsgIsMarked: store.Marker().IsMarked(uid),
}
}
for iter.Next() { if store.ThreadedView() {
thread := iter.Value().(*types.Thread) var (
var lastSubject string lastSubject string
err := thread.Walk(func(t *types.Thread, _ int, currentErr error) error { prevThread *types.Thread
if currentErr != nil { i int = 0
return currentErr )
} factory := iterator.NewFactory(!store.ReverseThreadOrder())
if t.Hidden || t.Deleted { threadLoop:
for iter := store.ThreadsIterator(); iter.Next(); {
var cur []*types.Thread
err := iter.Value().(*types.Thread).Walk(
func(t *types.Thread, _ int, _ error,
) error {
if t.Hidden || t.Deleted {
return nil
}
cur = append(cur, t)
return nil return nil
} })
if err != nil {
logging.Errorf("thread walk: %v", err)
}
for curIter := factory.NewIterator(cur); curIter.Next(); {
if i < ml.Scroll() { if i < ml.Scroll() {
i++ i++
return nil continue
} }
i++ if thread := curIter.Value().(*types.Thread); thread != nil {
msg := store.Messages[t.Uid] fmtCtx := createBaseCtx(thread.Uid, row)
var prefix string fmtCtx.ThreadPrefix = threadPrefix(thread,
var subject string store.ReverseThreadOrder())
var normalizedSubject string if fmtCtx.MsgInfo != nil && fmtCtx.MsgInfo.Envelope != nil {
if msg != nil { baseSubject, _ := sortthread.GetBaseSubject(
prefix = threadPrefix(t) fmtCtx.MsgInfo.Envelope.Subject)
if msg.Envelope != nil { fmtCtx.ThreadSameSubject = baseSubject == lastSubject &&
subject = msg.Envelope.Subject sameParent(thread, prevThread) &&
normalizedSubject, _ = sortthread.GetBaseSubject(subject) !isParent(thread)
lastSubject = baseSubject
prevThread = thread
} }
if ml.drawRow(textWidth, ctx, thread.Uid, row, &needsHeaders, fmtCtx) {
break threadLoop
}
row += 1
} }
fmtCtx := format.Ctx{
FromAddress: acct.acct.From,
AccountName: acct.Name(),
MsgInfo: msg,
MsgNum: row,
MsgIsMarked: store.Marker().IsMarked(t.Uid),
ThreadPrefix: prefix,
ThreadSameSubject: normalizedSubject == lastSubject,
}
if ml.drawRow(textWidth, ctx, t.Uid, row, &needsHeaders, fmtCtx) {
return types.ErrSkipThread
}
lastSubject = normalizedSubject
row++
return nil
})
if err != nil {
logging.Warnf("failed to walk threads: %v", err)
}
if row >= ctx.Height() {
break
} }
} }
} else { } else {
@ -148,15 +153,7 @@ func (ml *MessageList) Draw(ctx *ui.Context) {
continue continue
} }
uid := iter.Value().(uint32) uid := iter.Value().(uint32)
fmtCtx := createBaseCtx(uid, row)
msg := store.Messages[uid]
fmtCtx := format.Ctx{
FromAddress: acct.acct.From,
AccountName: acct.Name(),
MsgInfo: msg,
MsgNum: row,
MsgIsMarked: store.Marker().IsMarked(uid),
}
if ml.drawRow(textWidth, ctx, uid, row, &needsHeaders, fmtCtx) { if ml.drawRow(textWidth, ctx, uid, row, &needsHeaders, fmtCtx) {
break break
} }
@ -429,13 +426,17 @@ func (ml *MessageList) drawEmptyMessage(ctx *ui.Context) {
uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT), "%s", msg) uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT), "%s", msg)
} }
func threadPrefix(t *types.Thread) string { func threadPrefix(t *types.Thread, reverse bool) string {
var arrow string var arrow string
if t.Parent != nil { if t.Parent != nil {
if t.NextSibling != nil { if t.NextSibling != nil {
arrow = "├─>" arrow = "├─>"
} else { } else {
arrow = "└─>" if reverse {
arrow = "┌─>"
} else {
arrow = "└─>"
}
} }
} }
var prefix []string var prefix []string
@ -458,3 +459,11 @@ func threadPrefix(t *types.Thread) string {
ps := strings.Join(prefix, "") ps := strings.Join(prefix, "")
return fmt.Sprintf("%v%v", ps, arrow) return fmt.Sprintf("%v%v", ps, arrow)
} }
func sameParent(left, right *types.Thread) bool {
return left.Root() == right.Root()
}
func isParent(t *types.Thread) bool {
return t == t.Root()
}