turbo-editors/turbo-corepublic Fork 0
v1.0.1
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

tree.go · 229 lines · 6.6 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 17h ago1// Package filetree models a project's files as a tree that can be walked,
2// expanded and collapsed, and shown in a window of the editor's own.
3//
4// The model here knows nothing about drawing: it reads directories, keeps
5// track of what is open, and flattens itself into the rows a widget would
6// paint. That is what lets it be tested by making files and comparing values,
7// with no screen and no event loop.
8//
9// tree, err := filetree.New(".")
10// if err != nil {
11// return err
12// }
13// for _, row := range tree.Rows() {
14// fmt.Println(strings.Repeat(" ", row.Depth), row.Node.Name())
15// }
16package filetree
17
18import (
19 "fmt"
20 "os"
21 "path/filepath"
22 "sort"
23)
24
25// hiddenName is the one directory the tree never shows.
26//
27// Every other dot-entry is left in on purpose: the editor's own directory,
28// .gitignore and
29// .qlty are files of the project that someone may well want to open, and a
30// tree that hides them makes the editor's own settings unreachable from it.
31// .git is the exception because nothing inside it is meant to be edited by
32// hand, and it is large enough to bury everything else.
33const hiddenName = ".git"
34
35// Node is one file or directory in the tree.
36//
37// A directory reads its contents the first time it is expanded, not before, so
38// opening a tree on a large project costs one directory listing rather than a
39// walk of the whole thing.
40type Node struct {
41 name string
42 path string
43 isDir bool
44 expanded bool
45 loaded bool
46 children []*Node
47}
48
49// Name returns the entry's own name, without any directory part.
50func (n *Node) Name() string { return n.name }
51
52// Path returns the entry's absolute path, which is what opening it needs.
53func (n *Node) Path() string { return n.path }
54
55// IsDir reports whether the entry is a directory.
56func (n *Node) IsDir() bool { return n.isDir }
57
58// Expanded reports whether a directory is showing its contents.
59func (n *Node) Expanded() bool { return n.expanded }
60
61// Children returns a directory's entries, which is empty until it has been
62// expanded at least once.
63func (n *Node) Children() []*Node { return n.children }
64
65// Expand opens a directory, reading it if this is the first time.
66//
67// A file, and a directory that cannot be read, are left as they are — the
68// second shows as open with nothing in it rather than as an error, and a
69// later Refresh will try again.
70func (n *Node) Expand() {
71 if !n.isDir {
72 return
73 }
74 if !n.loaded {
75 n.children = readChildren(n.path)
76 n.loaded = true
77 }
78 n.expanded = true
79}
80
81// Collapse closes a directory, keeping what it has already read so reopening
82// it is free.
83func (n *Node) Collapse() { n.expanded = false }
84
85// Toggle opens a closed directory and closes an open one.
86//
87// if node.IsDir() {
88// node.Toggle()
89// }
90func (n *Node) Toggle() {
91 if n.expanded {
92 n.Collapse()
93 return
94 }
95 n.Expand()
96}
97
98// Tree is a project's files, rooted at one directory.
99type Tree struct {
100 root *Node
101}
102
103// New reads a directory and returns the tree rooted at it, with the root
104// already open so its contents show.
105//
106// A path that is not a directory, or cannot be read at all, is an error: the
107// caller has nothing to show and should say so rather than open an empty
108// window.
109//
110// tree, err := filetree.New("/src/myproject")
111func New(root string) (*Tree, error) {
112 absolute, err := filepath.Abs(root)
113 if err != nil {
114 return nil, fmt.Errorf("resolving %s: %w", root, err)
115 }
116
117 info, err := os.Stat(absolute)
118 if err != nil {
119 return nil, fmt.Errorf("reading %s: %w", absolute, err)
120 }
121 if !info.IsDir() {
122 return nil, fmt.Errorf("%s is not a directory", absolute)
123 }
124
125 t := &Tree{root: &Node{name: filepath.Base(absolute), path: absolute, isDir: true}}
126 t.root.Expand()
127 return t, nil
128}
129
130// Root returns the directory the tree is rooted at, which is what a window
131// holding the tree is named after.
132func (t *Tree) Root() *Node { return t.root }
133
134// Row is one line of the flattened tree: a node and how deep it sits.
135type Row struct {
136 Node *Node
137 Depth int
138}
139
140// Rows returns the visible lines, top to bottom.
141//
142// The root itself is not a row — the window's title carries it — so the first
143// row is the first entry inside the project.
144//
145// for _, row := range tree.Rows() {
146// draw(row.Depth, row.Node.Name())
147// }
148func (t *Tree) Rows() []Row {
149 var rows []Row
150 appendRows(&rows, t.root.children, 0)
151 return rows
152}
153
154// appendRows walks the open branches depth first.
155func appendRows(rows *[]Row, nodes []*Node, depth int) {
156 for _, node := range nodes {
157 *rows = append(*rows, Row{Node: node, Depth: depth})
158 if node.isDir && node.expanded {
159 appendRows(rows, node.children, depth+1)
160 }
161 }
162}
163
164// Refresh re-reads every directory the tree has already read, so that files
165// made outside the editor turn up.
166//
167// The shape is kept: a directory that was open stays open, and one that has
168// been deleted takes its branch with it. Directories that were never opened
169// stay unread, so refreshing a large project is as cheap as what is on screen.
170//
171// tree.Refresh() // after a build, or when F5 is pressed
172func (t *Tree) Refresh() { t.root.refresh() }
173
174// refresh re-reads one directory and then the ones below it that were read.
175func (n *Node) refresh() {
176 if !n.isDir || !n.loaded {
177 return
178 }
179
180 previous := make(map[string]*Node, len(n.children))
181 for _, child := range n.children {
182 previous[child.name] = child
183 }
184
185 n.children = readChildren(n.path)
186 for _, child := range n.children {
187 before, ok := previous[child.name]
188 if !ok || !before.isDir || !child.isDir {
189 continue // new, or no longer the same kind of thing
190 }
191 child.expanded, child.loaded, child.children = before.expanded, before.loaded, before.children
192 child.refresh()
193 }
194}
195
196// readChildren lists a directory: its subdirectories first, then its files,
197// each group sorted by name.
198//
199// A directory that cannot be read gives no children rather than an error. One
200// unreadable directory in a project is not a reason to refuse to show the rest
201// of it, and Refresh will pick it up if its permissions change.
202func readChildren(directory string) []*Node {
203 entries, err := os.ReadDir(directory)
204 if err != nil {
205 return nil
206 }
207
208 var directories, files []*Node
209 for _, entry := range entries {
210 if entry.Name() == hiddenName {
211 continue
212 }
213
214 node := &Node{
215 name: entry.Name(),
216 path: filepath.Join(directory, entry.Name()),
217 isDir: entry.IsDir(),
218 }
219 if node.isDir {
220 directories = append(directories, node)
221 } else {
222 files = append(files, node)
223 }
224 }
225
226 sort.Slice(directories, func(i, j int) bool { return directories[i].name < directories[j].name })
227 sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name })
228 return append(directories, files...)
229}