mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-02-22 22:06:21 -05:00
This fixes the inline file preview for rendered files (e.g., markdown). [Here, a live issue in v11](https://v11.next.forgejo.org/mahlzahn/test-inline-file-preview/issues/1) and [the same in v7 (with even more bugs)](https://v7.next.forgejo.org/mahlzahn/test-inline-file-preview/issues/1). It fixes 1. the inline preview for possibly rendered files, when the link is specified with `?display=source`. This happens, e.g., if you are watching a (e.g., markdown) file in source and then want to link some of its lines. 2. the link to the source file inside the inline preview for possible rendered files (currently it links to the rendered version and then the `#L…` cannot point to the correct lines). This is done by always adding `?display=source` to the link. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6572 Reviewed-by: Gusted <gusted@noreply.codeberg.org> Co-authored-by: Robert Wolff <mahlzahn@posteo.de> Co-committed-by: Robert Wolff <mahlzahn@posteo.de>
370 lines
8.8 KiB
Go
370 lines
8.8 KiB
Go
// Copyright The Forgejo Authors.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package markup
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"html/template"
|
|
"io"
|
|
"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
|
|
title template.HTML
|
|
subTitle template.HTML
|
|
lineOffset int
|
|
start int
|
|
end int
|
|
isTruncated bool
|
|
}
|
|
|
|
func NewFilePreviews(ctx *RenderContext, node *html.Node, locale translation.Locale) []*FilePreview {
|
|
if setting.FilePreviewMaxLines == 0 {
|
|
// Feature is disabled
|
|
return nil
|
|
}
|
|
|
|
mAll := filePreviewPattern.FindAllStringSubmatchIndex(node.Data, -1)
|
|
if mAll == nil {
|
|
return nil
|
|
}
|
|
|
|
result := make([]*FilePreview, 0)
|
|
|
|
for _, m := range mAll {
|
|
if slices.Contains(m, -1) {
|
|
continue
|
|
}
|
|
|
|
preview := newFilePreview(ctx, node, locale, m)
|
|
if preview != nil {
|
|
result = append(result, preview)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func newFilePreview(ctx *RenderContext, node *html.Node, locale translation.Locale, m []int) *FilePreview {
|
|
preview := &FilePreview{}
|
|
|
|
urlFull := node.Data[m[0]:m[1]]
|
|
|
|
// Ensure that we only use links to local repositories
|
|
if !strings.HasPrefix(urlFull, setting.AppURL) {
|
|
return nil
|
|
}
|
|
|
|
projPath := strings.TrimPrefix(strings.TrimSuffix(node.Data[m[0]:m[3]], "/"), setting.AppURL)
|
|
|
|
commitSha := node.Data[m[4]:m[5]]
|
|
filePath := node.Data[m[6]:m[7]]
|
|
urlFullSource := urlFull
|
|
if strings.HasSuffix(filePath, "?display=source") {
|
|
filePath = strings.TrimSuffix(filePath, "?display=source")
|
|
} else if Type(filePath) != "" {
|
|
urlFullSource = node.Data[m[0]:m[6]] + filePath + "?display=source#" + node.Data[m[8]:m[1]]
|
|
}
|
|
hash := node.Data[m[8]:m[9]]
|
|
|
|
preview.start = m[0]
|
|
preview.end = m[1]
|
|
|
|
projPathSegments := strings.Split(projPath, "/")
|
|
if len(projPathSegments) != 2 {
|
|
return nil
|
|
}
|
|
|
|
ownerName := projPathSegments[len(projPathSegments)-2]
|
|
repoName := projPathSegments[len(projPathSegments)-1]
|
|
|
|
var language string
|
|
fileBlob, err := DefaultProcessorHelper.GetRepoFileBlob(
|
|
ctx.Ctx,
|
|
ownerName,
|
|
repoName,
|
|
commitSha, filePath,
|
|
&language,
|
|
)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
titleBuffer := new(bytes.Buffer)
|
|
|
|
isExternRef := ownerName != ctx.Metas["user"] || repoName != ctx.Metas["repo"]
|
|
if isExternRef {
|
|
err = html.Render(titleBuffer, createLink(node.Data[m[0]:m[3]], ownerName+"/"+repoName, ""))
|
|
if err != nil {
|
|
log.Error("failed to render repoLink: %v", err)
|
|
}
|
|
titleBuffer.WriteString(" – ")
|
|
}
|
|
|
|
err = html.Render(titleBuffer, createLink(urlFullSource, filePath, "muted"))
|
|
if err != nil {
|
|
log.Error("failed to render filepathLink: %v", err)
|
|
}
|
|
|
|
preview.title = template.HTML(titleBuffer.String())
|
|
|
|
lineSpecs := strings.Split(hash, "-")
|
|
|
|
commitLinkBuffer := new(bytes.Buffer)
|
|
commitLinkText := commitSha[0:7]
|
|
if isExternRef {
|
|
commitLinkText = ownerName + "/" + repoName + "@" + commitLinkText
|
|
}
|
|
|
|
err = html.Render(commitLinkBuffer, createLink(node.Data[m[0]:m[5]], commitLinkText, "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 || err == io.EOF {
|
|
lineBuffer.Write(buf)
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
// 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"}},
|
|
}
|
|
|
|
ptitle := &html.Node{
|
|
Type: html.ElementNode,
|
|
Data: atom.Div.String(),
|
|
}
|
|
ptitle.AppendChild(&html.Node{
|
|
Type: html.RawNode,
|
|
Data: string(p.title),
|
|
})
|
|
header.AppendChild(ptitle)
|
|
|
|
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
|
|
}
|