mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-10-28 21:11:17 +00:00
Backport: https://codeberg.org/forgejo/forgejo/pulls/2669 (cherry picked from commit1d3240887c) (cherry picked from commit781a37fbe1) (cherry picked from commit8309f008c2) (cherry picked from commitfae8d9f70d) (cherry picked from commit6721cba75b) (cherry picked from commit562e5cdf32) (cherry picked from commitd789d33229) (cherry picked from commit8218e80bfc) (cherry picked from commit10bca456a9) (cherry picked from commitdb6f6281fc) (cherry picked from commited8e8a792e) (cherry picked from commitd6428f92ce) (cherry picked from commit069d87b80f) (cherry picked from commit2b6546adc9) (cherry picked from commit4c7cb0a5d2) (cherry picked from commit7e0014dd13) (cherry picked from commit16a8658878) (cherry picked from commit6e98bacbbd)
323 lines
7.6 KiB
Go
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
|
|
}
|