master
  1package main
  2
  3import (
  4	"bytes"
  5	"context"
  6	"fmt"
  7	"html/template"
  8	"os"
  9	"path/filepath"
 10	"runtime"
 11	"sort"
 12	"strings"
 13	"sync"
 14
 15	"github.com/alecthomas/chroma/v2"
 16	"github.com/alecthomas/chroma/v2/formatters/html"
 17	"github.com/alecthomas/chroma/v2/lexers"
 18	"github.com/alecthomas/chroma/v2/styles"
 19
 20	"github.com/antonmedv/gitmal/pkg/git"
 21	"github.com/antonmedv/gitmal/pkg/gitdiff"
 22	"github.com/antonmedv/gitmal/pkg/progress_bar"
 23	"github.com/antonmedv/gitmal/pkg/templates"
 24)
 25
 26func generateCommits(commits map[string]git.Commit, params Params) error {
 27	outDir := filepath.Join(params.OutputDir, "commit")
 28	if err := os.MkdirAll(outDir, 0o755); err != nil {
 29		return err
 30	}
 31
 32	list := make([]git.Commit, 0, len(commits))
 33	for _, c := range commits {
 34		list = append(list, c)
 35	}
 36
 37	workers := runtime.NumCPU()
 38	if workers < 1 {
 39		workers = 1
 40	}
 41
 42	ctx, cancel := context.WithCancel(context.Background())
 43	defer cancel()
 44
 45	jobs := make(chan git.Commit)
 46	errCh := make(chan error, 1)
 47	var wg sync.WaitGroup
 48
 49	p := progress_bar.NewProgressBar("commits", len(list))
 50
 51	workerFn := func() {
 52		defer wg.Done()
 53		for {
 54			select {
 55			case <-ctx.Done():
 56				return
 57			case c, ok := <-jobs:
 58				if !ok {
 59					return
 60				}
 61				if err := generateCommitPage(c, params); err != nil {
 62					select {
 63					case errCh <- err:
 64						cancel()
 65					default:
 66					}
 67					return
 68				}
 69				p.Inc()
 70			}
 71		}
 72	}
 73
 74	wg.Add(workers)
 75	for i := 0; i < workers; i++ {
 76		go workerFn()
 77	}
 78
 79	go func() {
 80		defer close(jobs)
 81		for _, c := range list {
 82			select {
 83			case <-ctx.Done():
 84				return
 85			case jobs <- c:
 86			}
 87		}
 88	}()
 89
 90	done := make(chan struct{})
 91	go func() {
 92		wg.Wait()
 93		close(done)
 94	}()
 95
 96	var err error
 97	select {
 98	case err = <-errCh:
 99		cancel()
100		<-done
101	case <-done:
102	}
103
104	p.Done()
105	return err
106}
107
108func generateCommitPage(commit git.Commit, params Params) error {
109	diff, err := git.CommitDiff(commit.Hash, params.RepoDir)
110	if err != nil {
111		return err
112	}
113
114	files, _, err := gitdiff.Parse(strings.NewReader(diff))
115	if err != nil {
116		return err
117	}
118
119	style := styles.Get(params.Style)
120	if style == nil {
121		return fmt.Errorf("unknown style: %s", params.Style)
122	}
123
124	formatter := html.New(
125		html.WithClasses(true),
126		html.WithCSSComments(false),
127		html.WithCustomCSS(map[chroma.TokenType]string{
128			chroma.GenericInserted: "display: block;",
129			chroma.GenericDeleted:  "display: block;",
130		}),
131	)
132
133	var cssBuf bytes.Buffer
134	if err := formatter.WriteCSS(&cssBuf, style); err != nil {
135		return err
136	}
137
138	lexer := lexers.Get("diff")
139	if lexer == nil {
140		return fmt.Errorf("failed to get lexer for diff")
141	}
142
143	outPath := filepath.Join(params.OutputDir, "commit", commit.Hash+".html")
144
145	f, err := os.Create(outPath)
146	if err != nil {
147		return err
148	}
149	rootHref := filepath.ToSlash("../")
150
151	fileTree := buildFileTree(files)
152
153	// Create a stable order for files that matches the file tree traversal
154	// so that the per-file views appear in the same order as the sidebar tree.
155	fileOrder := make(map[string]int)
156	{
157		// Preorder traversal (dirs first, then files), respecting sortNode ordering
158		var idx int
159		var walk func(nodes []*templates.FileTree)
160		walk = func(nodes []*templates.FileTree) {
161			for _, n := range nodes {
162				if n.IsDir {
163					// Children are already sorted by sortNode
164					walk(n.Children)
165					continue
166				}
167				if n.Path == "" {
168					continue
169				}
170				if _, ok := fileOrder[n.Path]; !ok {
171					fileOrder[n.Path] = idx
172					idx++
173				}
174			}
175		}
176		walk(fileTree)
177	}
178
179	// Prepare per-file views
180	var filesViews []templates.FileView
181	for _, f := range files {
182		path := f.NewName
183		if f.IsDelete {
184			path = f.OldName
185		}
186		if path == "" {
187			continue
188		}
189
190		var fileDiff strings.Builder
191		for _, frag := range f.TextFragments {
192			fileDiff.WriteString(frag.String())
193		}
194
195		it, err := lexer.Tokenise(nil, fileDiff.String())
196		if err != nil {
197			return err
198		}
199		var buf bytes.Buffer
200		if err := formatter.Format(&buf, style, it); err != nil {
201			return err
202		}
203
204		filesViews = append(filesViews, templates.FileView{
205			Path:       path,
206			OldName:    f.OldName,
207			NewName:    f.NewName,
208			IsNew:      f.IsNew,
209			IsDelete:   f.IsDelete,
210			IsRename:   f.IsRename,
211			IsBinary:   f.IsBinary,
212			HasChanges: f.TextFragments != nil,
213			HTML:       template.HTML(buf.String()),
214		})
215	}
216
217	// Sort file views to match the file tree order. If for some reason a path
218	// is missing in the order map (shouldn't happen), fall back to case-insensitive
219	// alphabetical order by full path.
220	sort.Slice(filesViews, func(i, j int) bool {
221		oi, iok := fileOrder[filesViews[i].Path]
222		oj, jok := fileOrder[filesViews[j].Path]
223		if iok && jok {
224			return oi < oj
225		}
226		if iok != jok {
227			return iok // known order first
228		}
229		return filesViews[i].Path < filesViews[j].Path
230	})
231
232	currentRef := params.DefaultRef
233	if !commit.Branch.IsEmpty() {
234		currentRef = commit.Branch
235	}
236
237	err = templates.CommitTemplate.ExecuteTemplate(f, "layout.gohtml", templates.CommitParams{
238		LayoutParams: templates.LayoutParams{
239			Title:         fmt.Sprintf("%s %s %s@%s", commit.Subject, dot, params.Name, commit.ShortHash),
240			Name:          params.Name,
241			SiteName:      params.SiteName,
242			Dark:          params.Dark,
243			RootHref:      rootHref + params.RootPrefix,
244			RepoHref:      rootHref,
245			CurrentRefDir: currentRef.DirName(),
246			Selected:      "commits",
247			InlineStyles:  params.InlineStyles,
248		},
249		Commit:    commit,
250		DiffCSS:   template.CSS(cssBuf.String()),
251		FileTree:  fileTree,
252		FileViews: filesViews,
253	})
254	if err != nil {
255		_ = f.Close()
256		return err
257	}
258	if err := f.Close(); err != nil {
259		return err
260	}
261	return nil
262}
263
264func buildFileTree(files []*gitdiff.File) []*templates.FileTree {
265	// Use a synthetic root (not rendered), collect top-level nodes in a map first.
266	root := &templates.FileTree{IsDir: true, Name: "", Path: "", Children: nil}
267
268	for _, f := range files {
269		path := f.NewName
270		if f.IsDelete {
271			path = f.OldName
272		}
273
274		path = filepath.ToSlash(strings.TrimPrefix(path, "./"))
275		if path == "" {
276			continue
277		}
278		parts := strings.Split(path, "/")
279
280		parent := root
281		accum := ""
282		if len(parts) > 1 {
283			for i := 0; i < len(parts)-1; i++ {
284				if accum == "" {
285					accum = parts[i]
286				} else {
287					accum = accum + "/" + parts[i]
288				}
289				parent = findOrCreateDir(parent, parts[i], accum)
290			}
291		}
292
293		fileName := parts[len(parts)-1]
294		node := &templates.FileTree{
295			Name:     fileName,
296			Path:     path,
297			IsDir:    false,
298			IsNew:    f.IsNew,
299			IsDelete: f.IsDelete,
300			IsRename: f.IsRename,
301			OldName:  f.OldName,
302			NewName:  f.NewName,
303		}
304		parent.Children = append(parent.Children, node)
305	}
306
307	sortNode(root)
308	return root.Children
309}
310
311func findOrCreateDir(parent *templates.FileTree, name, path string) *templates.FileTree {
312	for _, ch := range parent.Children {
313		if ch.IsDir && ch.Name == name {
314			return ch
315		}
316	}
317	node := &templates.FileTree{IsDir: true, Name: name, Path: path}
318	parent.Children = append(parent.Children, node)
319	return node
320}
321
322func sortNode(n *templates.FileTree) {
323	if len(n.Children) == 0 {
324		return
325	}
326	sort.Slice(n.Children, func(i, j int) bool {
327		a, b := n.Children[i], n.Children[j]
328		if a.IsDir != b.IsDir {
329			return a.IsDir && !b.IsDir // dirs first
330		}
331		return strings.ToLower(a.Name) < strings.ToLower(b.Name)
332	})
333	for _, ch := range n.Children {
334		if ch.IsDir {
335			sortNode(ch)
336		}
337	}
338}