diff options
| author | Anton Medvedev <anton@medv.io> | 2025-11-30 17:44:25 +0100 |
|---|---|---|
| committer | Anton Medvedev <anton@medv.io> | 2025-11-30 17:44:25 +0100 |
| commit | ed5c5009e85de3947fd7d25e09df117eda86ea42 (patch) | |
| tree | cec1d845e1790ee5c6fe0d335d63ccb1608ff827 /commit.go | |
| parent | 4106bdfd70306ab46b835144c9741c5f9864de9f (diff) | |
Rename `commits.go` to `commit.go` for consistency
Diffstat (limited to 'commit.go')
| -rw-r--r-- | commit.go | 329 |
1 files changed, 329 insertions, 0 deletions
diff --git a/commit.go b/commit.go new file mode 100644 index 0000000..57f4d03 --- /dev/null +++ b/commit.go @@ -0,0 +1,329 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "html/template" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + + "github.com/antonmedv/gitmal/pkg/git" + "github.com/antonmedv/gitmal/pkg/gitdiff" + "github.com/antonmedv/gitmal/pkg/progress_bar" + "github.com/antonmedv/gitmal/pkg/templates" +) + +func generateCommits(commits map[string]git.Commit, params Params) error { + outDir := filepath.Join(params.OutputDir, "commit") + if err := os.MkdirAll(outDir, 0o755); err != nil { + return err + } + + list := make([]git.Commit, 0, len(commits)) + for _, c := range commits { + list = append(list, c) + } + + workers := runtime.NumCPU() + if workers < 1 { + workers = 1 + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + jobs := make(chan git.Commit) + errCh := make(chan error, 1) + var wg sync.WaitGroup + + p := progress_bar.NewProgressBar("commits", len(list)) + + workerFn := func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case c, ok := <-jobs: + if !ok { + return + } + if err := generateCommitPage(c, params); err != nil { + select { + case errCh <- err: + cancel() + default: + } + return + } + p.Inc() + } + } + } + + wg.Add(workers) + for i := 0; i < workers; i++ { + go workerFn() + } + + go func() { + defer close(jobs) + for _, c := range list { + select { + case <-ctx.Done(): + return + case jobs <- c: + } + } + }() + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + var err error + select { + case err = <-errCh: + cancel() + <-done + case <-done: + } + + p.Done() + return err +} + +func generateCommitPage(commit git.Commit, params Params) error { + diff, err := git.CommitDiff(commit.Hash, params.RepoDir) + if err != nil { + return err + } + + files, _, err := gitdiff.Parse(strings.NewReader(diff)) + if err != nil { + return err + } + + style := styles.Get(params.Style) + if style == nil { + return fmt.Errorf("unknown style: %s", params.Style) + } + + formatter := html.New( + html.WithClasses(true), + html.WithCSSComments(false), + html.WithCustomCSS(map[chroma.TokenType]string{ + chroma.GenericInserted: "display: block;", + chroma.GenericDeleted: "display: block;", + }), + ) + + var cssBuf bytes.Buffer + if err := formatter.WriteCSS(&cssBuf, style); err != nil { + return err + } + + lexer := lexers.Get("diff") + if lexer == nil { + return fmt.Errorf("failed to get lexer for diff") + } + + outPath := filepath.Join(params.OutputDir, "commit", commit.Hash+".html") + + f, err := os.Create(outPath) + if err != nil { + return err + } + rootHref := filepath.ToSlash("../") + + fileTree := buildFileTree(files) + + // Create a stable order for files that matches the file tree traversal + // so that the per-file views appear in the same order as the sidebar tree. + fileOrder := make(map[string]int) + { + // Preorder traversal (dirs first, then files), respecting sortNode ordering + var idx int + var walk func(nodes []*templates.FileTree) + walk = func(nodes []*templates.FileTree) { + for _, n := range nodes { + if n.IsDir { + // Children are already sorted by sortNode + walk(n.Children) + continue + } + if n.Path == "" { + continue + } + if _, ok := fileOrder[n.Path]; !ok { + fileOrder[n.Path] = idx + idx++ + } + } + } + walk(fileTree) + } + + // Prepare per-file views + var filesViews []templates.FileView + for _, f := range files { + path := f.NewName + if f.IsDelete { + path = f.OldName + } + if path == "" { + continue + } + + var fileDiff strings.Builder + for _, frag := range f.TextFragments { + fileDiff.WriteString(frag.String()) + } + + it, err := lexer.Tokenise(nil, fileDiff.String()) + if err != nil { + return err + } + var buf bytes.Buffer + if err := formatter.Format(&buf, style, it); err != nil { + return err + } + + filesViews = append(filesViews, templates.FileView{ + Path: path, + OldName: f.OldName, + NewName: f.NewName, + IsNew: f.IsNew, + IsDelete: f.IsDelete, + IsRename: f.IsRename, + IsBinary: f.IsBinary, + HTML: template.HTML(buf.String()), + }) + } + + // Sort file views to match the file tree order. If for some reason a path + // is missing in the order map (shouldn't happen), fall back to case-insensitive + // alphabetical order by full path. + sort.Slice(filesViews, func(i, j int) bool { + oi, iok := fileOrder[filesViews[i].Path] + oj, jok := fileOrder[filesViews[j].Path] + if iok && jok { + return oi < oj + } + if iok != jok { + return iok // known order first + } + return filesViews[i].Path < filesViews[j].Path + }) + + err = templates.CommitTemplate.ExecuteTemplate(f, "layout.gohtml", templates.CommitParams{ + LayoutParams: templates.LayoutParams{ + Title: fmt.Sprintf("%s — %s", params.Name, commit.ShortHash), + Name: params.Name, + Dark: params.Dark, + RootHref: rootHref, + CurrentRef: params.Ref, + Selected: "commits", + }, + Commit: commit, + DiffCSS: template.CSS(cssBuf.String()), + FileTree: fileTree, + FileViews: filesViews, + }) + if err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return nil +} + +func buildFileTree(files []*gitdiff.File) []*templates.FileTree { + // Use a synthetic root (not rendered), collect top-level nodes in a map first. + root := &templates.FileTree{IsDir: true, Name: "", Path: "", Children: nil} + + for _, f := range files { + path := f.NewName + if f.IsDelete { + path = f.OldName + } + + path = filepath.ToSlash(strings.TrimPrefix(path, "./")) + if path == "" { + continue + } + parts := strings.Split(path, "/") + + parent := root + accum := "" + if len(parts) > 1 { + for i := 0; i < len(parts)-1; i++ { + if accum == "" { + accum = parts[i] + } else { + accum = accum + "/" + parts[i] + } + parent = findOrCreateDir(parent, parts[i], accum) + } + } + + fileName := parts[len(parts)-1] + node := &templates.FileTree{ + Name: fileName, + Path: path, + IsDir: false, + IsNew: f.IsNew, + IsDelete: f.IsDelete, + IsRename: f.IsRename, + OldName: f.OldName, + NewName: f.NewName, + } + parent.Children = append(parent.Children, node) + } + + sortNode(root) + return root.Children +} + +func findOrCreateDir(parent *templates.FileTree, name, path string) *templates.FileTree { + for _, ch := range parent.Children { + if ch.IsDir && ch.Name == name { + return ch + } + } + node := &templates.FileTree{IsDir: true, Name: name, Path: path} + parent.Children = append(parent.Children, node) + return node +} + +func sortNode(n *templates.FileTree) { + if len(n.Children) == 0 { + return + } + sort.Slice(n.Children, func(i, j int) bool { + a, b := n.Children[i], n.Children[j] + if a.IsDir != b.IsDir { + return a.IsDir && !b.IsDir // dirs first + } + return strings.ToLower(a.Name) < strings.ToLower(b.Name) + }) + for _, ch := range n.Children { + if ch.IsDir { + sortNode(ch) + } + } +} |
