Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/web/code_tree.go

Line attribution on main · 198 line(s)

a464f892
Keenan Nemetz
15d ago
1 package web
2
3 import (
4 "net/url"
5 "sort"
6 "strings"
7
d37d301f
Keenan Nemetz
15h ago
8 "rodent.software/vole/core/git"
a464f892
Keenan Nemetz
15d ago
9 )
10
11 // maxTreeFiles bounds the server-rendered file tree. A repository larger than
12 // this still browses, but the tree shows the first slice rather than turning a
13 // page load into a very large DOM.
14 const maxTreeFiles = 4000
15
16 // treeNode is one directory or file in the code browser's left column. Path is
17 // always repository-relative; Open and Active are resolved against the file the
18 // main pane is showing so a deep link arrives with its ancestors expanded.
19 type treeNode struct {
20 Name string
21 Path string
22 Type string
23 Children []*treeNode
24 Open bool
25 Active bool
f6f4b1db
Keenan Nemetz
15d ago
26 // Status is A, M, D or R on review-tree file rows. The code browser leaves
27 // it empty.
28 Status string
a464f892
Keenan Nemetz
15d ago
29 }
30
31 // treeView is the context passed to the recursive tree template. Templates
c717d4eb
Keenan Nemetz
13d ago
32 // cannot build a struct literal, so this carries the repository, the ref and the
33 // change down the recursion.
34 //
628d4523
Keenan Nemetz
11d ago
35 // One context serves every tree because they are the same widget: the code
36 // browser leaves Review and Commit empty, a change's Files tab names the request,
37 // and a commit page names the commit. That field is all the difference between
38 // "open this blob" and "open this changed file".
a464f892
Keenan Nemetz
15d ago
39 type treeView struct {
c717d4eb
Keenan Nemetz
13d ago
40 Name string
41 Ref string
42 Review string
628d4523
Keenan Nemetz
11d ago
43 Commit string
c717d4eb
Keenan Nemetz
13d ago
44 Nodes []*treeNode
45 }
46
47 func treeContext(name, ref, review string, nodes []*treeNode) treeView {
48 return treeView{Name: name, Ref: ref, Review: review, Nodes: nodes}
a464f892
Keenan Nemetz
15d ago
49 }
50
628d4523
Keenan Nemetz
11d ago
51 // commitTreeContext is the tree of the files one commit touched. A row opens that
52 // file's diff in the commit's own pane rather than a blob at the commit.
53 func commitTreeContext(name, commit string, nodes []*treeNode) treeView {
54 return treeView{Name: name, Commit: commit, Nodes: nodes}
55 }
56
57 // Branch re-roots the same tree one level down. The recursive template passes its
58 // own context back so a child row keeps the repository and the change it belongs
59 // to instead of rebuilding them from a subset of the fields.
60 func (t treeView) Branch(nodes []*treeNode) treeView {
61 t.Nodes = nodes
62 return t
63 }
64
65 // Diff reports whether the rows are changed files rather than repository blobs: a
66 // review or a commit opens a diff pane and marks each row with its change status.
67 func (t treeView) Diff() bool { return t.Review != "" || t.Commit != "" }
68
69 // Href is where a row points: a blob at this ref, the file's diff inside the
70 // change whose id is Review, or the file's diff inside a commit. The tree and the
71 // pane it swaps share it so a link and its hx-get can never disagree.
72 func (t treeView) Href(path string) string {
73 switch {
74 case t.Review != "":
ed03b3d1
Keenan Nemetz
2h ago
75 return "/" + t.Name + "/changes/" + t.Review + "/files/" + pathURL(path)
628d4523
Keenan Nemetz
11d ago
76 case t.Commit != "":
ed03b3d1
Keenan Nemetz
2h ago
77 return "/" + t.Name + "/commit/" + t.Commit + "/" + pathURL(path)
c717d4eb
Keenan Nemetz
13d ago
78 }
ed03b3d1
Keenan Nemetz
2h ago
79 return "/" + t.Name + "/blob/" + url.PathEscape(t.Ref) + "/" + pathURL(path)
a464f892
Keenan Nemetz
15d ago
80 }
81
82 // buildFileTree flattens the repository's blobs and folds them back into the
83 // nested shape a file browser needs. Git has no empty directories to show.
84 func buildFileTree(g *git.Repo, commit, active string) ([]*treeNode, bool) {
85 entries, err := g.LsTreeR(commit)
86 if err != nil {
87 return nil, false
88 }
89 truncated := false
90 if len(entries) > maxTreeFiles {
91 entries = entries[:maxTreeFiles]
92 truncated = true
93 }
94 root := &treeNode{Type: "tree"}
95 for _, e := range entries {
96 if e.Type != "blob" {
97 continue
98 }
99 parts := strings.Split(e.Name, "/")
100 cur := root
101 for i, part := range parts {
102 path := strings.Join(parts[:i+1], "/")
103 last := i == len(parts)-1
104 if last {
105 cur.Children = append(cur.Children, &treeNode{
106 Name: part, Path: path, Type: "blob", Active: path == active,
107 })
108 continue
109 }
110 var next *treeNode
111 for _, child := range cur.Children {
112 if child.Type == "tree" && child.Name == part {
113 next = child
114 break
115 }
116 }
117 if next == nil {
118 next = &treeNode{Name: part, Path: path, Type: "tree"}
119 cur.Children = append(cur.Children, next)
120 }
121 if active == path || strings.HasPrefix(active, path+"/") {
122 next.Open = true
123 }
124 if active == path {
125 next.Active = true
126 next.Open = true
127 }
128 cur = next
129 }
130 }
131 sortTree(root)
132 return root.Children, truncated
133 }
134
135 // sortEntries puts directories before files, then names in the same order the
136 // tree uses, so the branch root and the tree read alike.
137 func sortEntries(entries []git.Entry) {
138 sort.SliceStable(entries, func(i, j int) bool {
139 a, b := entries[i], entries[j]
140 if a.Type != b.Type {
141 return a.Type == "tree"
142 }
143 an, bn := strings.ToLower(a.Name), strings.ToLower(b.Name)
144 if an != bn {
145 return an < bn
146 }
147 return a.Name < b.Name
148 })
149 }
150
151 func sortTree(n *treeNode) {
152 sort.Slice(n.Children, func(i, j int) bool {
153 a, b := n.Children[i], n.Children[j]
154 if a.Type != b.Type {
155 return a.Type == "tree"
156 }
157 an, bn := strings.ToLower(a.Name), strings.ToLower(b.Name)
158 if an != bn {
159 return an < bn
160 }
161 return a.Name < b.Name
162 })
163 for _, child := range n.Children {
164 if child.Type == "tree" {
165 sortTree(child)
166 }
167 }
168 }
169
170 // pathURL escapes each segment of a repository path while keeping the slashes
171 // that make it a path. url.PathEscape alone would encode the separators.
172 func pathURL(p string) string {
173 if p == "" {
174 return ""
175 }
176 parts := strings.Split(p, "/")
177 for i := range parts {
178 parts[i] = url.PathEscape(parts[i])
179 }
180 return strings.Join(parts, "/")
181 }
182
183 // joinPath appends a directory entry to the current path.
184 func joinPath(base, name string) string {
185 if base == "" {
186 return name
187 }
188 return base + "/" + name
189 }
190
191 // parentPath returns the directory above p, or "" when p is already at the root.
192 func parentPath(p string) string {
193 i := strings.LastIndexByte(p, '/')
194 if i < 0 {
195 return ""
196 }
197 return p[:i]
198 }