GitRoot

craft your forge, build your project, grow your community freely
  1// SPDX-FileCopyrightText: 2025 Romain Maneschi <romain@gitroot.dev>
  2//
  3// SPDX-License-Identifier: EUPL-1.2
  4
  5package main
  6
  7import (
  8	"bytes"
  9	_ "embed"
 10	"encoding/json"
 11	"errors"
 12	"fmt"
 13	"io/fs"
 14	"mime"
 15	"path/filepath"
 16	"sort"
 17	"strings"
 18	"time"
 19
 20	"github.com/tidwall/gjson"
 21	gitroot "gitroot.dev/libs/golang/plugin"
 22)
 23
 24const cachePath string = "worktree"
 25
 26type gitWorktreeLine struct {
 27	FullPath       string
 28	Message        string
 29	LastEdited     time.Time
 30	Author         string
 31	hasBeenUpdated bool
 32}
 33
 34type worktree struct {
 35	p     *Plugin
 36	lines []gitWorktreeLine
 37}
 38
 39func (p *Plugin) LoadWorktree() {
 40	res := []gitWorktreeLine{}
 41	cache, err := fs.ReadFile(p.server.Cache(), cachePath)
 42	if err != nil && errors.Is(err, fs.ErrNotExist) {
 43		p.server.Log("cache not existing")
 44	} else if err != nil {
 45		p.server.LogError("can't read cache in LoadWorktree", err)
 46	} else {
 47		gjson.ForEachLine(string(cache), func(line gjson.Result) bool {
 48			res = append(res, unmarshalGitWorktreeLine(line))
 49			return true
 50		})
 51	}
 52	p.gitWorktree = &worktree{p: p, lines: res}
 53}
 54
 55func (p *Plugin) StoreWorktree() {
 56	var buf bytes.Buffer
 57	for _, line := range p.gitWorktree.lines {
 58		buf.Write(marshalGitWorktreeLine(line))
 59		buf.WriteString("\n")
 60	}
 61	p.server.ModifyCacheContent(cachePath, buf.String())
 62}
 63
 64func marshalGitWorktreeLine(line gitWorktreeLine) []byte {
 65	j, _ := json.Marshal(line)
 66	return j
 67}
 68
 69func unmarshalGitWorktreeLine(line gjson.Result) gitWorktreeLine {
 70	return gitWorktreeLine{
 71		FullPath:       line.Get("FullPath").String(),
 72		Message:        line.Get("Message").String(),
 73		LastEdited:     line.Get("LastEdited").Time(),
 74		Author:         line.Get("Author").String(),
 75		hasBeenUpdated: false,
 76	}
 77}
 78
 79func (w *worktree) addOrModFile(path string, commit gitroot.Commit) {
 80	w.delFile(path)
 81	w.lines = append(w.lines, gitWorktreeLine{
 82		FullPath:       path,
 83		Message:        replaceEmoji(commit.Message),
 84		LastEdited:     commit.Date,
 85		Author:         "// TODO",
 86		hasBeenUpdated: true,
 87	})
 88}
 89
 90func (w *worktree) delFile(path string) {
 91	dir, _ := filepath.Split(path)
 92	res := make([]gitWorktreeLine, 0)
 93	for _, line := range w.lines {
 94		if line.FullPath != path {
 95			if strings.HasPrefix(line.FullPath, dir) {
 96				res = append(res, gitWorktreeLine{
 97					FullPath:       line.FullPath,
 98					Message:        line.Message,
 99					LastEdited:     line.LastEdited,
100					Author:         line.Author,
101					hasBeenUpdated: true,
102				})
103			} else {
104				res = append(res, line)
105			}
106		}
107	}
108	w.lines = res
109}
110
111type worktreeLine struct {
112	name       string
113	icon       string
114	targetLink string
115	lastEdited time.Time
116	author     string
117	message    string
118	isDir      bool
119}
120
121func (w *worktree) preRender(dir string, prependDir string, finalizeRender func(fp string, htmlContent string)) []worktreeLine {
122	res := make([]worktreeLine, 0)
123	depthDir := strings.Count(dir, "/")
124	dirToRender := make(map[string]any)
125	for _, line := range w.lines {
126		if strings.HasPrefix(line.FullPath, dir) {
127			dirLine, filename := filepath.Split(line.FullPath)
128			//dir
129			if strings.Count(line.FullPath, "/") > depthDir {
130				currentDir := strings.Split(strings.TrimPrefix(dirLine, dir), "/")[0]
131				if _, ok := dirToRender[currentDir]; !ok {
132					nextRenderPath := fmt.Sprintf("%s%s/", dir, currentDir)
133					children := w.renderHtml(nextRenderPath, prependDir, finalizeRender)
134					dirToRender[currentDir] = 1
135					if len(children) > 0 {
136						lastCommit := children[0]
137						for _, c := range children {
138							if !c.isDir && c.lastEdited.Compare(lastCommit.lastEdited) > 0 {
139								lastCommit = c
140							}
141						}
142						res = append(res, worktreeLine{name: currentDir, icon: "📁", targetLink: fmt.Sprintf("%s/index.html", currentDir), lastEdited: lastCommit.lastEdited, author: lastCommit.author, message: lastCommit.message, isDir: true})
143					} else {
144						res = append(res, worktreeLine{name: currentDir, icon: "📁", targetLink: fmt.Sprintf("%s/index.html", currentDir), lastEdited: line.LastEdited, author: line.Author, message: line.Message, isDir: true})
145					}
146				}
147			}
148			//file
149			if strings.Count(line.FullPath, "/") == depthDir {
150				icon := getIcon(line.FullPath)
151				if line.hasBeenUpdated {
152					w.renderFile(line.FullPath, prependDir, finalizeRender)
153				}
154				res = append(res, worktreeLine{name: filename, icon: icon, targetLink: fmt.Sprintf("%s.html", filename), lastEdited: line.LastEdited, author: line.Author, message: line.Message, isDir: false})
155			}
156		}
157	}
158	return res
159}
160
161func (w *worktree) renderHtml(dir string, prependDir string, finalizeRender func(fp string, htmlContent string)) []worktreeLine {
162	// early return if nothing has change for this dir
163	atLeastOneChange := false
164	for _, line := range w.lines {
165		atLeastOneChange = line.hasBeenUpdated && strings.HasPrefix(line.FullPath, dir)
166		if atLeastOneChange {
167			break
168		}
169	}
170	if !atLeastOneChange {
171		return []worktreeLine{}
172	}
173
174	fullFilePath := filepath.Join(prependDir, dir)
175	var buf bytes.Buffer
176	buf.WriteString(renderBreadcrump(fullFilePath, false))
177	buf.WriteString("<table><tr><th></th><th>Name</th><th>Message</th><th>Last modified</th></tr>")
178
179	lines := w.preRender(dir, prependDir, finalizeRender)
180	sortedLines := byPath(lines)
181	sort.Sort(sortedLines)
182	for _, line := range lines {
183		buf.WriteString("<tr>")
184		msg := strings.SplitN(line.message, "\n", 2)
185		comMsg := msg[0]
186
187		details := ""
188		if len(msg) > 1 {
189			details = line.message
190		}
191		buf.WriteString(fmt.Sprintf("<td>%s</td><td><a href=\"%s\">%s</a></td><td><span title=\"%s\">%s</span></td><td>%s</td>", line.icon, line.targetLink, line.name, details, comMsg, line.lastEdited.Format("02-01-2006")))
192		buf.WriteString("</tr>")
193	}
194	buf.WriteString("</table>")
195	finalizeRender(fmt.Sprintf("%s/index.html", fullFilePath), buf.String())
196	return lines
197}
198
199type byPath []worktreeLine
200
201func (a byPath) Len() int {
202	return len(a)
203}
204func (a byPath) Swap(i, j int) {
205	a[i], a[j] = a[j], a[i]
206}
207func (a byPath) Less(i, j int) bool {
208	if a[i].isDir == a[j].isDir {
209		return strings.Compare(a[i].name, a[j].name) < 0
210	}
211	return a[i].isDir
212}
213
214func (w *worktree) renderFile(path string, prependDir string, finalizeRender func(fp string, htmlContent string)) {
215	_, filename := filepath.Split(path)
216	ext := filepath.Ext(filename)
217	typeMime := mime.TypeByExtension(ext)
218	buf := bytes.NewBufferString("")
219	if strings.HasPrefix(typeMime, "text/") || filename == "allowed_signers" || filename == "makefile" {
220		content, err := fs.ReadFile(w.p.server.Worktree(), path)
221		if err != nil {
222			w.p.server.LogError("can't reafile "+path, err)
223		}
224		buf.WriteString(renderBreadcrump(filepath.Join(prependDir, path), true))
225		if code, ok := hilightByExt[ext]; ok {
226			ext = code
227		}
228		htmlHighlight(buf, string(content), ext, "")
229	} else if strings.HasPrefix(typeMime, "image/") || ext == ".svg" {
230		buf.WriteString(renderBreadcrump(filepath.Join(prependDir, path), true))
231		buf.WriteString("<pre><img src=\"")
232		nbParent := strings.Count(filepath.Join(prependDir, path), "/")
233		buf.WriteString(strings.Repeat("../", nbParent))
234		buf.WriteString(path)
235		buf.WriteString("\"/></pre>")
236	} else {
237		buf.WriteString(renderBreadcrump(filepath.Join(prependDir, path), true))
238		buf.WriteString("<pre>Binary</pre>")
239	}
240	finalizeRender(filepath.Join(prependDir, fmt.Sprintf("%s.html", path)), buf.String())
241}
242
243func renderBreadcrump(fullFilePath string, isFile bool) string {
244	var buf bytes.Buffer
245	buf.WriteString("<nav>")
246	fullFilePaths := strings.Split(fullFilePath, "/")
247	nbParent := len(fullFilePaths)
248	for i, dirName := range fullFilePaths {
249		nbParentRelative := len(fullFilePaths) - (1 + i)
250		if isFile {
251			nbParentRelative = nbParentRelative - 1
252		}
253		if i < nbParent-1 && nbParentRelative > 0 {
254			buf.WriteString("<a href=\"")
255			buf.WriteString(strings.Repeat("../", nbParentRelative))
256			buf.WriteString("\">")
257			buf.WriteString(dirName)
258			buf.WriteString("</a>")
259			buf.WriteString("<span>/<span>")
260		} else if i < nbParent-1 {
261			buf.WriteString("<a href=\"./\">")
262			buf.WriteString(dirName)
263			buf.WriteString("</a>")
264			buf.WriteString("<span>/<span>")
265		} else {
266			buf.WriteString(dirName)
267		}
268	}
269	buf.WriteString("</nav>")
270	return buf.String()
271}