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.
|
a464f892
Keenan Nemetz
15d ago
|
1 | package web |
| 2 | ||
| 3 | import ( | |
| 4 | "net/url" | |
| 5 | "sort" | |
| 6 | "strings" | |
| 7 | ||
|
d37d301f
Keenan Nemetz
18h 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
6h ago
|
75 | return "/" + t.Name + "/changes/" + t.Review + "/files/" + pathURL(path) |
|
628d4523
Keenan Nemetz
11d ago
|
76 | case t.Commit != "": |
|
ed03b3d1
Keenan Nemetz
6h ago
|
77 | return "/" + t.Name + "/commit/" + t.Commit + "/" + pathURL(path) |
|
c717d4eb
Keenan Nemetz
13d ago
|
78 | } |
|
ed03b3d1
Keenan Nemetz
6h 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 | } |