repos / pgit

static site generator for git
git clone https://github.com/picosh/pgit.git

commit
7224ece
parent
8d0c699
author
Eric Bower
date
2023-08-19 16:52:22 +0000 UTC
feat: nested tree folder structure

This feature is designed to support a folder/file structure that most
people are familiar with.  Instead of just having every single file in a
large list, we nest files inside of folders.

The main benefit here is that for large repos with a lot of files we
don't ask end-users to download a massive MB tree file.

It's more clicking -- which I don't like -- but this seems necessary
when there are 10k+ files in a project.
5 files changed,  +272, -89
M html/file.page.tmpl
+29, -2
 1@@ -1,10 +1,37 @@
 2 {{template "base" .}}
 3-{{define "title"}}{{.Path}}@{{.RevData.Name}}{{end}}
 4+{{define "title"}}{{.Item.Path}}@{{.RevData.Name}}{{end}}
 5 {{define "meta"}}
 6 <link rel="stylesheet" href="/syntax.css" />
 7 {{end}}
 8 
 9 {{define "content"}}
10-  <h2 class="text-lg">{{.Path}}</h2>
11+  <div class="text-md">
12+    {{range .Item.Crumbs}}
13+      <a href="{{.URL}}">{{.Text}}</a> {{if .IsLast}}{{else}}/{{end}}
14+    {{end}}
15+  </div>
16+
17+  {{if .Repo.HideTreeLastCommit}}
18+  {{else}}
19+  <div class="box">
20+    <div class="flex items-center justify-between">
21+      <div class="flex-1">
22+        <a href="{{.Item.CommitURL}}">{{.Item.Summary}}</a>
23+      </div>
24+      <div>
25+        <a href="{{.Item.CommitURL}}">{{.Item.CommitID}}</a>
26+      </div>
27+    </div>
28+
29+    <div class="flex items-center gap-xs">
30+      <span>{{.Item.Author.Name}}</span>
31+      <span>&centerdot;</span>
32+      <span>{{.Item.When}}</span>
33+    </div>
34+  </div>
35+  {{end}}
36+
37+  <h2 class="text-lg">{{.Item.Name}}</h2>
38+
39   {{.Contents}}
40 {{end }}
M html/log.page.tmpl
+5, -5
 1@@ -5,12 +5,12 @@
 2 
 3 {{define "content"}}
 4   <div>
 5+  <div><span class="font-bold">({{.NumCommits}})</span> commits</div>
 6   {{range .Logs}}
 7     <div class="box">
 8       <div class="flex justify-between items-center">
 9-        <div>
10-          <a href="{{.URL}}">{{.SummaryStr}}</a>
11-        </div>
12+        <a href="{{.URL}}" class="text-md">{{.SummaryStr}}</a>
13+
14         <div class="flex gap">
15           {{.ShortID}}
16 
17@@ -24,9 +24,9 @@
18         </div>
19       </div>
20 
21-      <div class="flex items-center">
22+      <div class="flex items-center gap-xs">
23         <span>{{.AuthorStr}}</span>
24-        <span>&nbsp;committed&nbsp;</span>
25+        <span>&centerdot;</span>
26         <span>{{.WhenStr}}</span>
27       </div>
28 
M html/tree.page.tmpl
+28, -15
 1@@ -5,24 +5,37 @@
 2 
 3 {{define "content"}}
 4   <div>
 5-  {{range .Tree}}
 6-    <div class="flex justify-between items-center gap my-sm border-b">
 7-      <div class="flex-1 tree-path">
 8-        <a href="{{.URL}}">{{.Path}}</a>
 9-      </div>
10-
11-      <div class="flex items-center gap">
12-        {{if $.Repo.HideTreeLastCommit}}
13+    <div class="text-md mb">
14+      {{range .Tree.Crumbs}}
15+        {{if .IsLast}}
16+          <span class="font-bold">{{.Text}}</span>
17         {{else}}
18-        <div class="flex-1 tree-commit">
19-          <a href="{{.CommitURL}}" title="{{.Summary}}">{{.When}}</a>
20-        </div>
21+          <a href="{{.URL}}">{{.Text}}</a> {{if .IsLast}}{{else}}/{{end}}
22         {{end}}
23-        <div class="tree-size">
24-          {{if .IsTextFile}}{{.NumLines}} L{{else}}{{.Size}}{{end}}
25+      {{end}}
26+    </div>
27+
28+    {{range .Tree.Items}}
29+      <div class="flex justify-between items-center gap p-sm border-b tree-row">
30+        <div class="flex-1 tree-path">
31+          <a href="{{.URL}}">{{.Name}}{{if .IsDir}}/{{end}}</a>
32+        </div>
33+
34+        <div class="flex items-center gap">
35+          {{if $.Repo.HideTreeLastCommit}}
36+          {{else}}
37+          <div class="flex-1 tree-commit">
38+            <a href="{{.CommitURL}}" title="{{.Summary}}">{{.When}}</a>
39+          </div>
40+          {{end}}
41+          <div class="tree-size">
42+            {{if .IsDir}}
43+            {{else}}
44+              {{if .IsTextFile}}{{.NumLines}} L{{else}}{{.Size}}{{end}}
45+            {{end}}
46+          </div>
47         </div>
48       </div>
49-    </div>
50-  {{end}}
51+    {{end}}
52   </div>
53 {{end}}
M main.go
+193, -66
  1@@ -117,14 +117,19 @@ type CommitData struct {
  2 
  3 type TreeItem struct {
  4 	IsTextFile bool
  5+	IsDir      bool
  6 	Size       string
  7 	NumLines   int
  8+	Name       string
  9 	Path       string
 10 	URL        template.URL
 11+	CommitID string
 12 	CommitURL  template.URL
 13 	Summary    string
 14 	When       string
 15+	Author *git.Signature
 16 	Entry      *git.TreeEntry
 17+	Crumbs []*Breadcrumb
 18 }
 19 
 20 type DiffRender struct {
 21@@ -176,18 +181,19 @@ type SummaryPageData struct {
 22 
 23 type TreePageData struct {
 24 	*PageData
 25-	Tree []*TreeItem
 26+	Tree *TreeRoot
 27 }
 28 
 29 type LogPageData struct {
 30 	*PageData
 31-	Logs []*CommitData
 32+	NumCommits int
 33+	Logs       []*CommitData
 34 }
 35 
 36 type FilePageData struct {
 37 	*PageData
 38 	Contents template.HTML
 39-	Path     string
 40+	Item *TreeItem
 41 }
 42 
 43 type CommitPageData struct {
 44@@ -347,11 +353,11 @@ func (c *Config) writeRootSummary(data *PageData, readme template.HTML) {
 45 	})
 46 }
 47 
 48-func (c *Config) writeTree(data *PageData, tree []*TreeItem) {
 49-	c.Logger.Infof("writing tree (%s)", data.RevData.Name())
 50+func (c *Config) writeTree(data *PageData, tree *TreeRoot) {
 51+	c.Logger.Infof("writing tree (%s)", tree.Path)
 52 	c.writeHtml(&WriteData{
 53 		Filename: "index.html",
 54-		Subdir:   getTreeBaseDir(data.RevData),
 55+		Subdir:   tree.Path,
 56 		Template: "html/tree.page.tmpl",
 57 		Data: &TreePageData{
 58 			PageData: data,
 59@@ -367,8 +373,9 @@ func (c *Config) writeLog(data *PageData, logs []*CommitData) {
 60 		Subdir:   getLogBaseDir(data.RevData),
 61 		Template: "html/log.page.tmpl",
 62 		Data: &LogPageData{
 63-			PageData: data,
 64-			Logs:     logs,
 65+			PageData:   data,
 66+			NumCommits: len(logs),
 67+			Logs:       logs,
 68 		},
 69 	})
 70 }
 71@@ -414,7 +421,7 @@ func (c *Config) writeHTMLTreeFile(pageData *PageData, treeItem *TreeItem) strin
 72 		Data: &FilePageData{
 73 			PageData: pageData,
 74 			Contents: template.HTML(contents),
 75-			Path:     treeItem.Path,
 76+			Item: treeItem,
 77 		},
 78 		Subdir: getFileURL(pageData.RevData, d),
 79 	})
 80@@ -522,8 +529,12 @@ func getLogBaseDir(info RevInfo) string {
 81 	return filepath.Join("/", "logs", subdir)
 82 }
 83 
 84+func getFileBaseDir(info RevInfo) string {
 85+	return filepath.Join(getTreeBaseDir(info), "item")
 86+}
 87+
 88 func getFileURL(info RevInfo, fname string) string {
 89-	return filepath.Join(getTreeBaseDir(info), "item", fname)
 90+	return filepath.Join(getFileBaseDir(info), fname)
 91 }
 92 
 93 func getTreeURL(info RevInfo) template.URL {
 94@@ -672,33 +683,164 @@ func (c *Config) writeRepo() *BranchOutput {
 95 	return mainOutput
 96 }
 97 
 98+type TreeRoot struct {
 99+	Path string
100+	Items []*TreeItem
101+	Crumbs []*Breadcrumb
102+}
103+
104 type TreeWalker struct {
105-	revData  *RevData
106-	treeItem chan *TreeItem
107+	treeItem           chan *TreeItem
108+	tree               chan *TreeRoot
109+	HideTreeLastCommit bool
110+	PageData           *PageData
111+	Repo               *git.Repository
112+}
113+
114+type Breadcrumb struct {
115+	Text string
116+	URL template.URL
117+	IsLast bool
118+}
119+
120+func (tw *TreeWalker) calcBreadcrumbs(curpath string) []*Breadcrumb {
121+	if curpath == "" {
122+		return []*Breadcrumb{}
123+	}
124+	parts := strings.Split(curpath, string(os.PathSeparator))
125+	rootURL := template.URL(
126+		filepath.Join(
127+			getTreeBaseDir(tw.PageData.RevData),
128+			"index.html",
129+		),
130+	)
131+
132+	crumbs := make([]*Breadcrumb, len(parts)+1)
133+	crumbs[0] = &Breadcrumb {
134+		URL: rootURL,
135+		Text: tw.PageData.Repo.RepoName,
136+	}
137+
138+	cur := ""
139+	for idx, d := range parts {
140+		crumbs[idx+1] = &Breadcrumb{
141+			Text: d,
142+			URL: template.URL(filepath.Join(getFileBaseDir(tw.PageData.RevData), cur, d, "index.html")),
143+		}
144+		if idx == len(parts) - 1 {
145+			crumbs[idx+1].IsLast = true
146+		}
147+		cur = filepath.Join(cur, d)
148+	}
149+
150+	return crumbs
151+}
152+
153+func (tw *TreeWalker) NewTreeItem(entry *git.TreeEntry, curpath string, crumbs []*Breadcrumb) *TreeItem {
154+	typ := entry.Type()
155+	fname := filepath.Join(curpath, entry.Name())
156+	item := &TreeItem{
157+		Size:  toPretty(entry.Size()),
158+		Name:  entry.Name(),
159+		Path:  fname,
160+		Entry: entry,
161+		URL:   template.URL(getFileURL(tw.PageData.RevData, fname)),
162+		Crumbs: crumbs,
163+	}
164+
165+	// `git rev-list` is pretty expensive here, so we have a flag to disable
166+	if tw.HideTreeLastCommit {
167+		// c.Logger.Info("skipping the process of finding the last commit for each file")
168+	} else {
169+		id := tw.PageData.RevData.ID()
170+		lastCommits, err := tw.Repo.RevList([]string{id}, git.RevListOptions{
171+			Path:           item.Path,
172+			CommandOptions: git.CommandOptions{Args: []string{"-1"}},
173+		})
174+		bail(err)
175+
176+		var lc *git.Commit
177+		if len(lastCommits) > 0 {
178+			lc = lastCommits[0]
179+		}
180+		item.CommitURL = getCommitURL(lc.ID.String())
181+		item.CommitID = getShortID(lc.ID.String())
182+		item.Summary = lc.Summary()
183+		item.When = lc.Author.When.Format("02 Jan 06")
184+		item.Author = lc.Author
185+	}
186+
187+	fpath := getFileURL(
188+		tw.PageData.RevData,
189+		fmt.Sprintf("%s.html", fname),
190+	)
191+	if typ == git.ObjectTree {
192+		item.IsDir = true
193+		fpath = filepath.Join(
194+			getFileBaseDir(tw.PageData.RevData),
195+			curpath,
196+			entry.Name(),
197+			"index.html",
198+		)
199+	}
200+	item.URL = template.URL(fpath)
201+
202+	return item
203 }
204 
205 func (tw *TreeWalker) walk(tree *git.Tree, curpath string) {
206 	entries, err := tree.Entries()
207 	bail(err)
208 
209+	crumbs := tw.calcBreadcrumbs(curpath)
210+	treeEntries := []*TreeItem{}
211 	for _, entry := range entries {
212-		fname := filepath.Join(curpath, entry.Name())
213 		typ := entry.Type()
214+		item := tw.NewTreeItem(entry, curpath, crumbs)
215 
216 		if typ == git.ObjectTree {
217+			item.IsDir = true
218 			re, _ := tree.Subtree(entry.Name())
219-			tw.walk(re, fname)
220+			tw.walk(re, item.Path)
221+			treeEntries = append(treeEntries, item)
222+			tw.treeItem <- item
223 		} else if typ == git.ObjectBlob {
224-			tw.treeItem <- &TreeItem{
225-				Size:  toPretty(entry.Size()),
226-				Path:  fname,
227-				Entry: entry,
228-				URL:   template.URL(getFileURL(tw.revData, fname)),
229-			}
230+			treeEntries = append(treeEntries, item)
231+			tw.treeItem <- item
232 		}
233 	}
234 
235+	sort.Slice(treeEntries, func(i, j int) bool {
236+		nameI := treeEntries[i].Name
237+		nameJ := treeEntries[j].Name
238+		if treeEntries[i].IsDir && treeEntries[j].IsDir {
239+			return nameI < nameJ
240+		}
241+
242+		if treeEntries[i].IsDir && !treeEntries[j].IsDir {
243+			return true
244+		}
245+
246+		return nameI < nameJ
247+	})
248+
249+	fpath := filepath.Join(
250+		getFileBaseDir(tw.PageData.RevData),
251+		curpath,
252+	)
253+	// root gets a special spot outside of `item` subdir
254 	if curpath == "" {
255+		fpath = getTreeBaseDir(tw.PageData.RevData)
256+	}
257+
258+	tw.tree <- &TreeRoot{
259+		Path: fpath,
260+		Items: treeEntries,
261+		Crumbs: crumbs,
262+	}
263+
264+	if curpath == "" {
265+		close(tw.tree)
266 		close(tw.treeItem)
267 	}
268 }
269@@ -773,12 +915,14 @@ func (c *Config) writeRevision(repo *git.Repository, pageData *PageData, refs []
270 	tree, err := repo.LsTree(pageData.RevData.ID())
271 	bail(err)
272 
273-	treeEntries := []*TreeItem{}
274 	readme := ""
275 	entries := make(chan *TreeItem)
276+	subtrees := make(chan *TreeRoot)
277 	tw := &TreeWalker{
278-		revData:  pageData.RevData,
279+		PageData: pageData,
280+		Repo:     repo,
281 		treeItem: entries,
282+		tree:     subtrees,
283 	}
284 	wg.Add(1)
285 	go func() {
286@@ -786,62 +930,45 @@ func (c *Config) writeRevision(repo *git.Repository, pageData *PageData, refs []
287 		tw.walk(tree, "")
288 	}()
289 
290-	for e := range entries {
291-		wg.Add(1)
292-		go func(entry *TreeItem) {
293-			defer wg.Done()
294-			entry.Path = strings.TrimPrefix(entry.Path, "/")
295-
296-			var lastCommits []*git.Commit
297-			// `git rev-list` is pretty expensive here, so we have a flag to disable
298-			if pageData.Repo.HideTreeLastCommit {
299-				// c.Logger.Info("skipping the process of finding the last commit for each file")
300-			} else {
301-				lastCommits, err = repo.RevList([]string{pageData.RevData.ID()}, git.RevListOptions{
302-					Path:           entry.Path,
303-					CommandOptions: git.CommandOptions{Args: []string{"-1"}},
304-				})
305-				bail(err)
306-
307-				var lc *git.Commit
308-				if len(lastCommits) > 0 {
309-					lc = lastCommits[0]
310+	wg.Add(1)
311+	go func() {
312+		defer wg.Done()
313+		for e := range entries {
314+			wg.Add(1)
315+			go func(entry *TreeItem) {
316+				defer wg.Done()
317+				if entry.IsDir {
318+					return
319 				}
320-				entry.CommitURL = getCommitURL(lc.ID.String())
321-				entry.Summary = lc.Summary()
322-				entry.When = lc.Author.When.Format("02 Jan 06")
323-			}
324 
325-			fpath := getFileURL(
326-				pageData.RevData,
327-				fmt.Sprintf("%s.html", entry.Path),
328-			)
329-			entry.URL = template.URL(fpath)
330+				readmeStr := c.writeHTMLTreeFile(pageData, entry)
331+				if readmeStr != "" {
332+					readme = readmeStr
333+				}
334+			}(e)
335+		}
336+	}()
337 
338-			readmeStr := c.writeHTMLTreeFile(pageData, entry)
339-			if readmeStr != "" {
340-				readme = readmeStr
341-			}
342-			treeEntries = append(treeEntries, entry)
343-		}(e)
344-	}
345+	wg.Add(1)
346+	go func() {
347+		defer wg.Done()
348+		for t := range subtrees {
349+			wg.Add(1)
350+			go func(tree *TreeRoot) {
351+				defer wg.Done()
352+				c.writeTree(pageData, tree)
353+			}(t)
354+		}
355+	}()
356 
357 	wg.Wait()
358 
359-	sort.Slice(treeEntries, func(i, j int) bool {
360-		nameI := treeEntries[i].Path
361-		nameJ := treeEntries[j].Path
362-		return nameI < nameJ
363-	})
364-
365 	c.Logger.Infof(
366 		"compilation complete (%s) branch (%s)",
367 		c.RepoName,
368 		pageData.RevData.Name(),
369 	)
370 
371-	c.writeTree(pageData, treeEntries)
372-
373 	output.Readme = readme
374 	return output
375 }
M static/main.css
+17, -1
 1@@ -140,7 +140,7 @@ hr {
 2   margin: 0;
 3   height: 1px;
 4   background: var(--grey);
 5-  margin: 2rem auto;
 6+  margin: 1rem auto;
 7   text-align: center;
 8 }
 9 
10@@ -314,6 +314,10 @@ figure {
11   margin-top: 0.5rem;
12 }
13 
14+.mb {
15+  margin-bottom: 0.5rem;
16+}
17+
18 .mt-lg {
19   margin-top: 1.35rem;
20 }
21@@ -343,6 +347,10 @@ figure {
22   margin-right: 1rem;
23 }
24 
25+.p-sm {
26+  padding: 0.5rem;
27+}
28+
29 .justify-between {
30   justify-content: space-between;
31 }
32@@ -355,6 +363,10 @@ figure {
33   gap: 1rem;
34 }
35 
36+.gap-xs {
37+  gap: 0.25rem;
38+}
39+
40 .border-b {
41   border-bottom: 1px solid #666;
42 }
43@@ -368,6 +380,10 @@ figure {
44   text-wrap: wrap;
45 }
46 
47+.tree-row:hover {
48+  background-color: var(--grey);
49+}
50+
51 @media only screen and (max-width: 900px) {
52   body {
53     padding: 1rem;