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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Code

395 commit(s) on main

update readme Keenan Nemetz · 23m ago 32290114
history blame raw 5.7 KiB
1package web
2
3import (
4 "net/url"
5 "sort"
6 "strings"
7
8 "rodent.software/vole/core/git"
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.
14const 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.
19type treeNode struct {
20 Name string
21 Path string
22 Type string
23 Children []*treeNode
24 Open bool
25 Active bool
26 // Status is A, M, D or R on review-tree file rows. The code browser leaves
27 // it empty.
28 Status string
29}
30
31// treeView is the context passed to the recursive tree template. Templates
32// cannot build a struct literal, so this carries the repository, the ref and the
33// change down the recursion.
34//
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".
39type treeView struct {
40 Name string
41 Ref string
42 Review string
43 Commit string
44 Nodes []*treeNode
45}
46
47func treeContext(name, ref, review string, nodes []*treeNode) treeView {
48 return treeView{Name: name, Ref: ref, Review: review, Nodes: nodes}
49}
50
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.
53func 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.
60func (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.
67func (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.
72func (t treeView) Href(path string) string {
73 switch {
74 case t.Review != "":
75 return "/" + t.Name + "/changes/" + t.Review + "/files/" + pathURL(path)
76 case t.Commit != "":
77 return "/" + t.Name + "/commit/" + t.Commit + "/" + pathURL(path)
78 }
79 return "/" + t.Name + "/blob/" + url.PathEscape(t.Ref) + "/" + pathURL(path)
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.
84func 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.
137func 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
151func 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.
172func 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.
184func 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.
192func parentPath(p string) string {
193 i := strings.LastIndexByte(p, '/')
194 if i < 0 {
195 return ""
196 }
197 return p[:i]
198}
199