package views import ( "sort" "strconv" "strings" "hearthforge/internal/db" "hearthforge/internal/highlight" g "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) // slugify turns a file path into the anchor id of its diff block. func slugify(s string) string { return "diff-" + strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { return r } return '-' }, s) } // statusLetter is the single letter shown in the status badge. func statusLetter(status highlight.DiffStatus) string { switch status { case highlight.StatusAdded: return "A" case highlight.StatusDeleted: return "D" case highlight.StatusRenamed: return "R" case highlight.StatusCopied: return "C" default: return "M" } } // diffTree is a directory tree of the changed files. It keeps insertion order // so the rendered tree matches the order of the diff. type diffTree struct { order []string nodes map[string]*diffTreeNode } type diffTreeNode struct { file *highlight.RenderedDiffFile children *diffTree } func newDiffTree() *diffTree { return &diffTree{nodes: map[string]*diffTreeNode{}} } func (t *diffTree) set(name string, node *diffTreeNode) { if _, ok := t.nodes[name]; !ok { t.order = append(t.order, name) } t.nodes[name] = node } func buildFileTree(files []highlight.RenderedDiffFile) *diffTree { root := newDiffTree() for i := range files { p := files[i].NewPath if p == "" { p = files[i].OldPath } parts := strings.Split(p, "/") current := root for _, part := range parts[:len(parts)-1] { node, ok := current.nodes[part] if !ok { node = &diffTreeNode{children: newDiffTree()} current.set(part, node) } if node.children != nil { current = node.children } } current.set(parts[len(parts)-1], &diffTreeNode{file: &files[i]}) } return root } // renderFileTree renders the file navigation, directories first. func renderFileTree(tree *diffTree) g.Node { names := append([]string(nil), tree.order...) sort.SliceStable(names, func(i, j int) bool { a, b := tree.nodes[names[i]], tree.nodes[names[j]] return a.children != nil && b.children == nil }) return Ul(Class("file-nav-list"), g.Map(names, func(name string) g.Node { node := tree.nodes[name] if node.children != nil { return Li(Class("file-nav-dir"), Details(Open(), Summary(Class("file-nav-dir-toggle"), Span(Class("file-nav-toggle-icon"), g.Text("▾")), Span(g.Text(name+"/")), ), renderFileTree(node.children), ), ) } f := node.file path := f.NewPath if path == "" { path = f.OldPath } return Li( A(Href("#"+slugify(path)), Class("file-nav-item"), Title(path), Span(Class("file-nav-status file-status-"+string(f.Status)), g.Text(statusLetter(f.Status))), Span(Class("file-nav-name"), g.Text(name)), Span(Class("file-nav-stat"), g.If(f.IsBinary, Span(Class("nav-binary"), g.If(f.HasBinarySize, g.Group{ Span(Class("nav-del"), g.Text(strconv.FormatInt(f.BinaryFrom, 10))), g.Text(" → "), Span(Class("nav-add"), g.Text(strconv.FormatInt(f.BinaryTo, 10))), }), g.If(!f.HasBinarySize, g.Text("Bin")), )), g.If(!f.IsBinary, g.Group{ g.If(f.Added > 0, Span(Class("nav-add"), g.Text("+"+strconv.Itoa(f.Added)))), g.If(f.Removed > 0, Span(Class("nav-del"), g.Text("-"+strconv.Itoa(f.Removed)))), }), ), ), ) }), ) } // plural appends "s" unless n is 1. func plural(n int, word string) string { if n == 1 { return word } return word + "s" } // DiffView renders the file navigation and the diff of every file. // sha is empty when no "view at sha" link should be shown. func DiffView(files []highlight.RenderedDiffFile, repo *db.Repo, sha string) g.Node { totalAdded, totalRemoved := 0, 0 for _, f := range files { totalAdded += f.Added totalRemoved += f.Removed } parts := []string{strconv.Itoa(len(files)) + " " + plural(len(files), "file") + " changed"} if totalAdded > 0 { parts = append(parts, strconv.Itoa(totalAdded)+" "+plural(totalAdded, "insertion")+"(+)") } if totalRemoved > 0 { parts = append(parts, strconv.Itoa(totalRemoved)+" "+plural(totalRemoved, "deletion")+"(-)") } changedStr := strings.Join(parts, ", ") if len(files) == 0 { return P(Class("text-muted"), Style("margin-top: var(--space-6)"), g.Text("Empty diff or no diff available.")) } return g.Group{ Div(Class("commit-stats-bar"), Span(Class("commit-stats-text"), g.Text(changedStr)), ), Div(Class("commit-layout"), Aside(Class("commit-file-nav"), Details(Class("file-nav-details"), Open(), Summary(Class("file-nav-toggle"), Span(Class("file-nav-toggle-icon"), g.Text("▾")), Span(g.Text("Files changed ("+strconv.Itoa(len(files))+")")), ), renderFileTree(buildFileTree(files)), ), ), Div(Class("commit-diffs"), g.Map(files, func(f highlight.RenderedDiffFile) g.Node { return diffFile(f, repo, sha) }), ), ), } } func diffFile(f highlight.RenderedDiffFile, repo *db.Repo, sha string) g.Node { displayPath := f.NewPath if displayPath == "" { displayPath = f.OldPath } shortSha := sha if len(shortSha) > 7 { shortSha = shortSha[:7] } return Details(Class("diff-file"), ID(slugify(displayPath)), Open(), Summary(Class("diff-file-header"), Div(Class("diff-file-header-left"), Span(Class("diff-file-toggle-icon"), g.Text("▾")), Span(Class("diff-status-badge diff-status-"+string(f.Status)), g.Text(statusLetter(f.Status))), Span(Class("diff-file-path mono"), g.Text(displayPath)), g.If(f.Status == highlight.StatusRenamed && f.OldPath != f.NewPath, Span(Class("diff-rename-arrow"), g.Text("← "+f.OldPath))), ), Div(Class("diff-file-header-right"), g.If(f.IsBinary, Span(Class("diff-stat-binary"), g.If(f.HasBinarySize, g.Group{ g.Text("Bin "), Span(Class("diff-stat-del"), g.Text(strconv.FormatInt(f.BinaryFrom, 10))), g.Text(" → "), Span(Class("diff-stat-add"), g.Text(strconv.FormatInt(f.BinaryTo, 10))), g.Text(" bytes"), }), g.If(!f.HasBinarySize, g.Text("Binary")), )), g.If(!f.IsBinary, g.Group{ g.If(f.Added > 0, Span(Class("diff-stat-add"), g.Text("+"+strconv.Itoa(f.Added)))), g.If(f.Removed > 0, Span(Class("diff-stat-del"), g.Text("-"+strconv.Itoa(f.Removed)))), }), g.If(f.Status != highlight.StatusDeleted, g.Group{ g.If(sha != "", A(Href("/"+repo.Name+"/blob/"+sha+"/"+EscapePath(displayPath)), Class("btn btn-xs btn-secondary"), Title("View file at "+shortSha), g.Text("@ "+shortSha))), A(Href("/"+repo.Name+"/blob/"+EscapePath(repo.DefaultBranch)+"/"+EscapePath(displayPath)), Class("btn btn-xs btn-secondary"), Title("View file at "+repo.DefaultBranch), g.Text("@ "+repo.DefaultBranch)), }), ), ), diffBody(f), ) } func diffBody(f highlight.RenderedDiffFile) g.Node { if f.IsBinary { return Div(Class("diff-binary-notice"), g.Text("Binary file — not shown")) } if len(f.Hunks) == 0 { return Div(Class("diff-binary-notice"), g.Text("No textual changes.")) } return Div(Class("diff-file-body"), g.Map(f.Hunks, func(hunk highlight.RenderedHunk) g.Node { return Div(Class("diff-hunk"), Table(Class("diff-table"), THead(Tr(Th(ColSpan("4"), Class("diff-hunk-header"), g.Text(hunk.Header)))), TBody(g.Map(hunk.Rows, diffRow)), ), ) }), ) } func diffRow(row highlight.RenderedRow) g.Node { lineNo := func(n int) g.Node { if n == highlight.NoLine { return nil } return g.Text(strconv.Itoa(n)) } sign := " " switch row.Type { case "add": sign = "+" case "del": sign = "-" } return Tr(Class("diff-row diff-row-"+row.Type), Td(Class("diff-ln diff-ln-old"), lineNo(row.OldLine)), Td(Class("diff-ln diff-ln-new"), lineNo(row.NewLine)), Td(Class("diff-sign"), g.Text(sign)), // The highlighter already escaped this HTML. Td(Class("diff-code"), g.Raw(row.HTML)), ) }