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}