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.

Turn the code tab into an editor-style file browser

a464f8927bfb3edcf170694f120dc9b3f68320da · Keenan Nemetz · 15d ago · parent f09ef39e

Turn the code tab into an editor-style file browser

- Add a persistent nested file tree with HTMX file and folder swaps, active
  rows, expanded ancestors, and deep-link support.
- Share one page header across every repository tab: title and summary on the
  left, tab actions grouped on the right.
- Make the repository name the first tab and show visibility with a lock or
  globe icon derived from the ACL.
- Open markdown as syntax-highlighted source by default, with an explicit
  full-width preview toggle.
- Point the branch breadcrumb at the Code overview instead of a second root
  directory listing.
- Move contributor-branch CI approval from the code browser to the Runs tab.
- Extend UI tests for the file tree, fragments, repo visibility, markdown
  source/preview, shared headers, and the moved CI control.

server/web/code_tree.go +196

@@ -0,0 +1,196 @@
1 package web
2
3 import (
4 "html/template"
5 "net/url"
6 "sort"
7 "strings"
8
9 "github.com/rodent-software/vole/core/git"
10 )
11
12 // codeView is the data shared by the full code page and the HTMX fragment that
13 // replaces its main pane. Fields under the blob section are only populated for
14 // blob views; the tree and shell fields are only populated for full pages.
15 type codeView struct {
16 Page
17 Name string
18 Ref string
19 Commit string
20 CloneURL string
21 SSHURL string
22 Site string
23 Count int
24 Branches []string
25 Latest []git.Commit
26 Tree []*treeNode
27 TreeTruncated bool
28
29 Mode string
30 Path string
31 Parent string
32 Crumbs []crumb
33 Entries []git.Entry
34
35 Readme template.HTML
36 ReadmeName string
37
38 Content string
39 Markdown template.HTML
40 Binary bool
41 Truncated bool
42 LineCut bool
43 Lines []string
44 Highlight template.HTML
45 Size int64
46 IsMarkdown bool
47 Preview bool
48 }
49
50 // maxTreeFiles bounds the server-rendered file tree. A repository larger than
51 // this still browses, but the tree shows the first slice rather than turning a
52 // page load into a very large DOM.
53 const maxTreeFiles = 4000
54
55 // treeNode is one directory or file in the code browser's left column. Path is
56 // always repository-relative; Open and Active are resolved against the file the
57 // main pane is showing so a deep link arrives with its ancestors expanded.
58 type treeNode struct {
59 Name string
60 Path string
61 Type string
62 Children []*treeNode
63 Open bool
64 Active bool
65 }
66
67 // treeView is the context passed to the recursive tree template. Templates
68 // cannot build a struct literal, so this carries the repository and ref down
69 // the recursion.
70 type treeView struct {
71 Name string
72 Ref string
73 Nodes []*treeNode
74 }
75
76 func treeContext(name, ref string, nodes []*treeNode) treeView {
77 return treeView{Name: name, Ref: ref, Nodes: nodes}
78 }
79
80 // buildFileTree flattens the repository's blobs and folds them back into the
81 // nested shape a file browser needs. Git has no empty directories to show.
82 func buildFileTree(g *git.Repo, commit, active string) ([]*treeNode, bool) {
83 entries, err := g.LsTreeR(commit)
84 if err != nil {
85 return nil, false
86 }
87 truncated := false
88 if len(entries) > maxTreeFiles {
89 entries = entries[:maxTreeFiles]
90 truncated = true
91 }
92 root := &treeNode{Type: "tree"}
93 for _, e := range entries {
94 if e.Type != "blob" {
95 continue
96 }
97 parts := strings.Split(e.Name, "/")
98 cur := root
99 for i, part := range parts {
100 path := strings.Join(parts[:i+1], "/")
101 last := i == len(parts)-1
102 if last {
103 cur.Children = append(cur.Children, &treeNode{
104 Name: part, Path: path, Type: "blob", Active: path == active,
105 })
106 continue
107 }
108 var next *treeNode
109 for _, child := range cur.Children {
110 if child.Type == "tree" && child.Name == part {
111 next = child
112 break
113 }
114 }
115 if next == nil {
116 next = &treeNode{Name: part, Path: path, Type: "tree"}
117 cur.Children = append(cur.Children, next)
118 }
119 if active == path || strings.HasPrefix(active, path+"/") {
120 next.Open = true
121 }
122 if active == path {
123 next.Active = true
124 next.Open = true
125 }
126 cur = next
127 }
128 }
129 sortTree(root)
130 return root.Children, truncated
131 }
132
133 // sortEntries puts directories before files, then names in the same order the
134 // tree uses, so the branch root and the tree read alike.
135 func sortEntries(entries []git.Entry) {
136 sort.SliceStable(entries, func(i, j int) bool {
137 a, b := entries[i], entries[j]
138 if a.Type != b.Type {
139 return a.Type == "tree"
140 }
141 an, bn := strings.ToLower(a.Name), strings.ToLower(b.Name)
142 if an != bn {
143 return an < bn
144 }
145 return a.Name < b.Name
146 })
147 }
148
149 func sortTree(n *treeNode) {
150 sort.Slice(n.Children, func(i, j int) bool {
151 a, b := n.Children[i], n.Children[j]
152 if a.Type != b.Type {
153 return a.Type == "tree"
154 }
155 an, bn := strings.ToLower(a.Name), strings.ToLower(b.Name)
156 if an != bn {
157 return an < bn
158 }
159 return a.Name < b.Name
160 })
161 for _, child := range n.Children {
162 if child.Type == "tree" {
163 sortTree(child)
164 }
165 }
166 }
167
168 // pathURL escapes each segment of a repository path while keeping the slashes
169 // that make it a path. url.PathEscape alone would encode the separators.
170 func pathURL(p string) string {
171 if p == "" {
172 return ""
173 }
174 parts := strings.Split(p, "/")
175 for i := range parts {
176 parts[i] = url.PathEscape(parts[i])
177 }
178 return strings.Join(parts, "/")
179 }
180
181 // joinPath appends a directory entry to the current path.
182 func joinPath(base, name string) string {
183 if base == "" {
184 return name
185 }
186 return base + "/" + name
187 }
188
189 // parentPath returns the directory above p, or "" when p is already at the root.
190 func parentPath(p string) string {
191 i := strings.LastIndexByte(p, '/')
192 if i < 0 {
193 return ""
194 }
195 return p[:i]
196 }