forgejo/modules/markup/file_preview.go
Mai-Lapyst 22aedc6c96
[v7.0/forgejo] Render inline file permalinks
Backport: https://codeberg.org/forgejo/forgejo/pulls/2669

(cherry picked from commit 1d3240887c)
(cherry picked from commit 781a37fbe1)
(cherry picked from commit 8309f008c2)
(cherry picked from commit fae8d9f70d)
(cherry picked from commit 6721cba75b)
(cherry picked from commit 562e5cdf32)
(cherry picked from commit d789d33229)
(cherry picked from commit 8218e80bfc)
(cherry picked from commit 10bca456a9)
(cherry picked from commit db6f6281fc)
(cherry picked from commit ed8e8a792e)
(cherry picked from commit d6428f92ce)
(cherry picked from commit 069d87b80f)
(cherry picked from commit 2b6546adc9)
(cherry picked from commit 4c7cb0a5d2)
(cherry picked from commit 7e0014dd13)
(cherry picked from commit 16a8658878)
(cherry picked from commit 6e98bacbbd)
2024-04-01 16:15:58 +02:00

324 lines
7.6 KiB
Go

// Copyright The Forgejo Authors.
// SPDX-License-Identifier: MIT
package markup
import (
"bufio"
"bytes"
"html/template"
"regexp"
"slices"
"strconv"
"strings"
"code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/highlight"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// filePreviewPattern matches "http://domain/org/repo/src/commit/COMMIT/filepath#L1-L2"
var filePreviewPattern = regexp.MustCompile(`https?://((?:\S+/){3})src/commit/([0-9a-f]{4,64})/(\S+)#(L\d+(?:-L\d+)?)`)
type FilePreview struct {
fileContent []template.HTML
subTitle template.HTML
lineOffset int
urlFull string
filePath string
start int
end int
isTruncated bool
}
func NewFilePreview(ctx *RenderContext, node *html.Node, locale translation.Locale) *FilePreview {
if setting.FilePreviewMaxLines == 0 {
// Feature is disabled
return nil
}
preview := &FilePreview{}
m := filePreviewPattern.FindStringSubmatchIndex(node.Data)
if m == nil {
return nil
}
// Ensure that every group has a match
if slices.Contains(m, -1) {
return nil
}
preview.urlFull = node.Data[m[0]:m[1]]
// Ensure that we only use links to local repositories
if !strings.HasPrefix(preview.urlFull, setting.AppURL+setting.AppSubURL) {
return nil
}
projPath := strings.TrimSuffix(node.Data[m[2]:m[3]], "/")
commitSha := node.Data[m[4]:m[5]]
preview.filePath = node.Data[m[6]:m[7]]
hash := node.Data[m[8]:m[9]]
preview.start = m[0]
preview.end = m[1]
projPathSegments := strings.Split(projPath, "/")
var language string
fileBlob, err := DefaultProcessorHelper.GetRepoFileBlob(
ctx.Ctx,
projPathSegments[len(projPathSegments)-2],
projPathSegments[len(projPathSegments)-1],
commitSha, preview.filePath,
&language,
)
if err != nil {
return nil
}
lineSpecs := strings.Split(hash, "-")
commitLinkBuffer := new(bytes.Buffer)
err = html.Render(commitLinkBuffer, createLink(node.Data[m[0]:m[5]], commitSha[0:7], "text black"))
if err != nil {
log.Error("failed to render commitLink: %v", err)
}
var startLine, endLine int
if len(lineSpecs) == 1 {
startLine, _ = strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
endLine = startLine
preview.subTitle = locale.Tr(
"markup.filepreview.line", startLine,
template.HTML(commitLinkBuffer.String()),
)
preview.lineOffset = startLine - 1
} else {
startLine, _ = strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
endLine, _ = strconv.Atoi(strings.TrimPrefix(lineSpecs[1], "L"))
preview.subTitle = locale.Tr(
"markup.filepreview.lines", startLine, endLine,
template.HTML(commitLinkBuffer.String()),
)
preview.lineOffset = startLine - 1
}
lineCount := endLine - (startLine - 1)
if startLine < 1 || endLine < 1 || lineCount < 1 {
return nil
}
if setting.FilePreviewMaxLines > 0 && lineCount > setting.FilePreviewMaxLines {
preview.isTruncated = true
lineCount = setting.FilePreviewMaxLines
}
dataRc, err := fileBlob.DataAsync()
if err != nil {
return nil
}
defer dataRc.Close()
reader := bufio.NewReader(dataRc)
// skip all lines until we find our startLine
for i := 1; i < startLine; i++ {
_, err := reader.ReadBytes('\n')
if err != nil {
return nil
}
}
// capture the lines we're interested in
lineBuffer := new(bytes.Buffer)
for i := 0; i < lineCount; i++ {
buf, err := reader.ReadBytes('\n')
if err != nil {
break
}
lineBuffer.Write(buf)
}
// highlight the file...
fileContent, _, err := highlight.File(fileBlob.Name(), language, lineBuffer.Bytes())
if err != nil {
log.Error("highlight.File failed, fallback to plain text: %v", err)
fileContent = highlight.PlainText(lineBuffer.Bytes())
}
preview.fileContent = fileContent
return preview
}
func (p *FilePreview) CreateHTML(locale translation.Locale) *html.Node {
table := &html.Node{
Type: html.ElementNode,
Data: atom.Table.String(),
Attr: []html.Attribute{{Key: "class", Val: "file-preview"}},
}
tbody := &html.Node{
Type: html.ElementNode,
Data: atom.Tbody.String(),
}
status := &charset.EscapeStatus{}
statuses := make([]*charset.EscapeStatus, len(p.fileContent))
for i, line := range p.fileContent {
statuses[i], p.fileContent[i] = charset.EscapeControlHTML(line, locale, charset.FileviewContext)
status = status.Or(statuses[i])
}
for idx, code := range p.fileContent {
tr := &html.Node{
Type: html.ElementNode,
Data: atom.Tr.String(),
}
lineNum := strconv.Itoa(p.lineOffset + idx + 1)
tdLinesnum := &html.Node{
Type: html.ElementNode,
Data: atom.Td.String(),
Attr: []html.Attribute{
{Key: "class", Val: "lines-num"},
},
}
spanLinesNum := &html.Node{
Type: html.ElementNode,
Data: atom.Span.String(),
Attr: []html.Attribute{
{Key: "data-line-number", Val: lineNum},
},
}
tdLinesnum.AppendChild(spanLinesNum)
tr.AppendChild(tdLinesnum)
if status.Escaped {
tdLinesEscape := &html.Node{
Type: html.ElementNode,
Data: atom.Td.String(),
Attr: []html.Attribute{
{Key: "class", Val: "lines-escape"},
},
}
if statuses[idx].Escaped {
btnTitle := ""
if statuses[idx].HasInvisible {
btnTitle += locale.TrString("repo.invisible_runes_line") + " "
}
if statuses[idx].HasAmbiguous {
btnTitle += locale.TrString("repo.ambiguous_runes_line")
}
escapeBtn := &html.Node{
Type: html.ElementNode,
Data: atom.Button.String(),
Attr: []html.Attribute{
{Key: "class", Val: "toggle-escape-button btn interact-bg"},
{Key: "title", Val: btnTitle},
},
}
tdLinesEscape.AppendChild(escapeBtn)
}
tr.AppendChild(tdLinesEscape)
}
tdCode := &html.Node{
Type: html.ElementNode,
Data: atom.Td.String(),
Attr: []html.Attribute{
{Key: "class", Val: "lines-code chroma"},
},
}
codeInner := &html.Node{
Type: html.ElementNode,
Data: atom.Code.String(),
Attr: []html.Attribute{{Key: "class", Val: "code-inner"}},
}
codeText := &html.Node{
Type: html.RawNode,
Data: string(code),
}
codeInner.AppendChild(codeText)
tdCode.AppendChild(codeInner)
tr.AppendChild(tdCode)
tbody.AppendChild(tr)
}
table.AppendChild(tbody)
twrapper := &html.Node{
Type: html.ElementNode,
Data: atom.Div.String(),
Attr: []html.Attribute{{Key: "class", Val: "ui table"}},
}
twrapper.AppendChild(table)
header := &html.Node{
Type: html.ElementNode,
Data: atom.Div.String(),
Attr: []html.Attribute{{Key: "class", Val: "header"}},
}
afilepath := &html.Node{
Type: html.ElementNode,
Data: atom.A.String(),
Attr: []html.Attribute{
{Key: "href", Val: p.urlFull},
{Key: "class", Val: "muted"},
},
}
afilepath.AppendChild(&html.Node{
Type: html.TextNode,
Data: p.filePath,
})
header.AppendChild(afilepath)
psubtitle := &html.Node{
Type: html.ElementNode,
Data: atom.Span.String(),
Attr: []html.Attribute{{Key: "class", Val: "text small grey"}},
}
psubtitle.AppendChild(&html.Node{
Type: html.RawNode,
Data: string(p.subTitle),
})
header.AppendChild(psubtitle)
node := &html.Node{
Type: html.ElementNode,
Data: atom.Div.String(),
Attr: []html.Attribute{{Key: "class", Val: "file-preview-box"}},
}
node.AppendChild(header)
if p.isTruncated {
warning := &html.Node{
Type: html.ElementNode,
Data: atom.Div.String(),
Attr: []html.Attribute{{Key: "class", Val: "ui warning message tw-text-left"}},
}
warning.AppendChild(&html.Node{
Type: html.TextNode,
Data: locale.TrString("markup.filepreview.truncated"),
})
node.AppendChild(warning)
}
node.AppendChild(twrapper)
return node
}