// Package filetree models a project's files as a tree that can be walked, // expanded and collapsed, and shown in a window of the editor's own. // // The model here knows nothing about drawing: it reads directories, keeps // track of what is open, and flattens itself into the rows a widget would // paint. That is what lets it be tested by making files and comparing values, // with no screen and no event loop. // // tree, err := filetree.New(".") // if err != nil { // return err // } // for _, row := range tree.Rows() { // fmt.Println(strings.Repeat(" ", row.Depth), row.Node.Name()) // } package filetree import ( "fmt" "os" "path/filepath" "sort" ) // hiddenName is the one directory the tree never shows. // // Every other dot-entry is left in on purpose: the editor's own directory, // .gitignore and // .qlty are files of the project that someone may well want to open, and a // tree that hides them makes the editor's own settings unreachable from it. // .git is the exception because nothing inside it is meant to be edited by // hand, and it is large enough to bury everything else. const hiddenName = ".git" // Node is one file or directory in the tree. // // A directory reads its contents the first time it is expanded, not before, so // opening a tree on a large project costs one directory listing rather than a // walk of the whole thing. type Node struct { name string path string isDir bool expanded bool loaded bool children []*Node } // Name returns the entry's own name, without any directory part. func (n *Node) Name() string { return n.name } // Path returns the entry's absolute path, which is what opening it needs. func (n *Node) Path() string { return n.path } // IsDir reports whether the entry is a directory. func (n *Node) IsDir() bool { return n.isDir } // Expanded reports whether a directory is showing its contents. func (n *Node) Expanded() bool { return n.expanded } // Children returns a directory's entries, which is empty until it has been // expanded at least once. func (n *Node) Children() []*Node { return n.children } // Expand opens a directory, reading it if this is the first time. // // A file, and a directory that cannot be read, are left as they are — the // second shows as open with nothing in it rather than as an error, and a // later Refresh will try again. func (n *Node) Expand() { if !n.isDir { return } if !n.loaded { n.children = readChildren(n.path) n.loaded = true } n.expanded = true } // Collapse closes a directory, keeping what it has already read so reopening // it is free. func (n *Node) Collapse() { n.expanded = false } // Toggle opens a closed directory and closes an open one. // // if node.IsDir() { // node.Toggle() // } func (n *Node) Toggle() { if n.expanded { n.Collapse() return } n.Expand() } // Tree is a project's files, rooted at one directory. type Tree struct { root *Node } // New reads a directory and returns the tree rooted at it, with the root // already open so its contents show. // // A path that is not a directory, or cannot be read at all, is an error: the // caller has nothing to show and should say so rather than open an empty // window. // // tree, err := filetree.New("/src/myproject") func New(root string) (*Tree, error) { absolute, err := filepath.Abs(root) if err != nil { return nil, fmt.Errorf("resolving %s: %w", root, err) } info, err := os.Stat(absolute) if err != nil { return nil, fmt.Errorf("reading %s: %w", absolute, err) } if !info.IsDir() { return nil, fmt.Errorf("%s is not a directory", absolute) } t := &Tree{root: &Node{name: filepath.Base(absolute), path: absolute, isDir: true}} t.root.Expand() return t, nil } // Root returns the directory the tree is rooted at, which is what a window // holding the tree is named after. func (t *Tree) Root() *Node { return t.root } // Row is one line of the flattened tree: a node and how deep it sits. type Row struct { Node *Node Depth int } // Rows returns the visible lines, top to bottom. // // The root itself is not a row — the window's title carries it — so the first // row is the first entry inside the project. // // for _, row := range tree.Rows() { // draw(row.Depth, row.Node.Name()) // } func (t *Tree) Rows() []Row { var rows []Row appendRows(&rows, t.root.children, 0) return rows } // appendRows walks the open branches depth first. func appendRows(rows *[]Row, nodes []*Node, depth int) { for _, node := range nodes { *rows = append(*rows, Row{Node: node, Depth: depth}) if node.isDir && node.expanded { appendRows(rows, node.children, depth+1) } } } // Refresh re-reads every directory the tree has already read, so that files // made outside the editor turn up. // // The shape is kept: a directory that was open stays open, and one that has // been deleted takes its branch with it. Directories that were never opened // stay unread, so refreshing a large project is as cheap as what is on screen. // // tree.Refresh() // after a build, or when F5 is pressed func (t *Tree) Refresh() { t.root.refresh() } // refresh re-reads one directory and then the ones below it that were read. func (n *Node) refresh() { if !n.isDir || !n.loaded { return } previous := make(map[string]*Node, len(n.children)) for _, child := range n.children { previous[child.name] = child } n.children = readChildren(n.path) for _, child := range n.children { before, ok := previous[child.name] if !ok || !before.isDir || !child.isDir { continue // new, or no longer the same kind of thing } child.expanded, child.loaded, child.children = before.expanded, before.loaded, before.children child.refresh() } } // readChildren lists a directory: its subdirectories first, then its files, // each group sorted by name. // // A directory that cannot be read gives no children rather than an error. One // unreadable directory in a project is not a reason to refuse to show the rest // of it, and Refresh will pick it up if its permissions change. func readChildren(directory string) []*Node { entries, err := os.ReadDir(directory) if err != nil { return nil } var directories, files []*Node for _, entry := range entries { if entry.Name() == hiddenName { continue } node := &Node{ name: entry.Name(), path: filepath.Join(directory, entry.Name()), isDir: entry.IsDir(), } if node.isDir { directories = append(directories, node) } else { files = append(files, node) } } sort.Slice(directories, func(i, j int) bool { return directories[i].name < directories[j].name }) sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name }) return append(directories, files...) }