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}