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.dev/libs/golang/plugin/model"
 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 model.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	res := make([]gitWorktreeLine, 0)
 92	for _, line := range w.lines {
 93		if line.FullPath != path {
 94			res = append(res, line)
 95		}
 96	}
 97	w.lines = res
 98}
 99
100type worktreeLine struct {
101	name       string
102	icon       string
103	targetLink string
104	lastEdited time.Time
105	author     string
106	message    string
107	isDir      bool
108}
109
110func (w *worktree) preRender(dir string, prependDir string, finalizeRender func(fp string, htmlContent string)) []worktreeLine {
111	res := make([]worktreeLine, 0)
112	depthDir := strings.Count(dir, "/")
113	dirToRender := make(map[string]any)
114	for _, line := range w.lines {
115		if strings.HasPrefix(line.FullPath, dir) {
116			dirLine, filename := filepath.Split(line.FullPath)
117			//dir
118			if strings.Count(line.FullPath, "/") > depthDir {
119				currentDir := strings.Split(strings.TrimPrefix(dirLine, dir), "/")[0]
120				if _, ok := dirToRender[currentDir]; !ok {
121					nextRenderPath := fmt.Sprintf("%s%s/", dir, currentDir)
122					children := w.renderHtml(nextRenderPath, prependDir, finalizeRender)
123					dirToRender[currentDir] = 1
124					if len(children) > 0 {
125						lastCommit := children[0]
126						for _, c := range children {
127							if !c.isDir && c.lastEdited.Compare(lastCommit.lastEdited) > 0 {
128								lastCommit = c
129							}
130						}
131						res = append(res, worktreeLine{name: currentDir, icon: "📁", targetLink: fmt.Sprintf("%s/%sindex.html", prependDir, nextRenderPath), lastEdited: lastCommit.lastEdited, author: lastCommit.author, message: lastCommit.message, isDir: true})
132					} else {
133						res = append(res, worktreeLine{name: currentDir, icon: "📁", targetLink: fmt.Sprintf("%s/%sindex.html", prependDir, nextRenderPath), lastEdited: line.LastEdited, author: line.Author, message: line.Message, isDir: true})
134					}
135				}
136			}
137			//file
138			if strings.Count(line.FullPath, "/") == depthDir {
139				icon := getIcon(line.FullPath)
140				if line.hasBeenUpdated {
141					w.renderFile(line.FullPath, prependDir, finalizeRender)
142				}
143				res = append(res, worktreeLine{name: filename, icon: icon, targetLink: fmt.Sprintf("%s/%s%s.html", prependDir, dirLine, filename), lastEdited: line.LastEdited, author: line.Author, message: line.Message, isDir: false})
144			}
145		}
146	}
147	return res
148}
149
150func (w *worktree) renderHtml(dir string, prependDir string, finalizeRender func(fp string, htmlContent string)) []worktreeLine {
151	// early return if nothing has change for this dir
152	atLeastOneChange := false
153	for _, line := range w.lines {
154		atLeastOneChange = line.hasBeenUpdated && strings.HasPrefix(line.FullPath, dir)
155		if atLeastOneChange {
156			break
157		}
158	}
159	if !atLeastOneChange {
160		return []worktreeLine{}
161	}
162
163	fullFilePath := filepath.Join(prependDir, dir)
164	var buf bytes.Buffer
165	buf.WriteString(renderBreadcrump(fullFilePath, false))
166	buf.WriteString("<table><tr><th></th><th>Name</th><th>Message</th><th>Last modified</th></tr>")
167
168	lines := w.preRender(dir, prependDir, finalizeRender)
169	sortedLines := byPath(lines)
170	sort.Sort(sortedLines)
171	for _, line := range lines {
172		buf.WriteString("<tr>")
173		msg := strings.SplitN(line.message, "\n", 2)
174		comMsg := msg[0]
175
176		details := ""
177		if len(msg) > 1 {
178			details = line.message
179		}
180		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")))
181		buf.WriteString("</tr>")
182	}
183	buf.WriteString("</table>")
184	finalizeRender(fmt.Sprintf("%s/index.html", fullFilePath), buf.String())
185	return lines
186}
187
188type byPath []worktreeLine
189
190func (a byPath) Len() int {
191	return len(a)
192}
193func (a byPath) Swap(i, j int) {
194	a[i], a[j] = a[j], a[i]
195}
196func (a byPath) Less(i, j int) bool {
197	if a[i].isDir == a[j].isDir {
198		return strings.Compare(a[i].name, a[j].name) < 0
199	}
200	return a[i].isDir
201}
202
203func (w *worktree) renderFile(path string, prependDir string, finalizeRender func(fp string, htmlContent string)) {
204	_, filename := filepath.Split(path)
205	ext := filepath.Ext(filename)
206	typeMime := mime.TypeByExtension(ext)
207	buf := bytes.NewBufferString("")
208	if strings.HasPrefix(typeMime, "text/") || filename == "allowed_signers" || filename == "makefile" {
209		content, err := fs.ReadFile(w.p.server.Worktree(), path)
210		if err != nil {
211			w.p.server.LogError("can't reafile "+path, err)
212		}
213		buf.WriteString(renderBreadcrump(filepath.Join(prependDir, path), true))
214		if code, ok := hilightByExt[ext]; ok {
215			ext = code
216		}
217		code := string(content)
218		lang := string(ext)
219		htmlCode, err := w.p.server.CallFunc(CODE_PLUGIN, "renderCode", map[string]string{"code": code, "lang": lang})
220		if err != nil {
221			w.p.server.LogError("renderCode call renderCode fail", err)
222			return
223		}
224		fmt.Fprint(buf, htmlCode["html"])
225	} else if strings.HasPrefix(typeMime, "image/") || ext == ".svg" {
226		buf.WriteString(renderBreadcrump(filepath.Join(prependDir, path), true))
227		buf.WriteString("<pre><img src=\"")
228		nbParent := strings.Count(filepath.Join(prependDir, path), "/")
229		buf.WriteString(strings.Repeat("../", nbParent))
230		buf.WriteString(path)
231		buf.WriteString("\"/></pre>")
232	} else {
233		buf.WriteString(renderBreadcrump(filepath.Join(prependDir, path), true))
234		buf.WriteString("<pre>Binary</pre>")
235	}
236	finalizeRender(filepath.Join(prependDir, fmt.Sprintf("%s.html", path)), buf.String())
237}
238
239func renderBreadcrump(fullFilePath string, isFile bool) string {
240	var buf bytes.Buffer
241	buf.WriteString("<nav>")
242	fullFilePaths := strings.Split(fullFilePath, "/")
243	currentPath := ""
244	for i, dirName := range fullFilePaths {
245		if i < len(fullFilePaths) {
246			currentPath = filepath.Join(currentPath, dirName)
247			buf.WriteString("<a href=\"")
248			buf.WriteString(currentPath)
249			buf.WriteString("\">")
250			buf.WriteString(dirName)
251			buf.WriteString("</a>")
252			buf.WriteString("<span>/<span>")
253		} else {
254			buf.WriteString(dirName)
255		}
256	}
257	buf.WriteString("</nav>")
258	return buf.String()
259}