forgejo/modules/markup/file_preview.go
2024-03-27 18:43:55 +01:00

323 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
}