turbo-editors/turbo-corepublic Fork 0
28d59854361aeda8541d853093e732126f3d7bff
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.

🛟 Updated. 28d5985 · on 28d59854361aeda8541d853093e732126f3d7bff · k33g · 14h ago
tree.go · 229 lines · 6.6 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
// 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...)
}