summaryrefslogtreecommitdiffstats
path: root/modules/markup/file_preview.go
diff options
context:
space:
mode:
Diffstat (limited to 'modules/markup/file_preview.go')
-rw-r--r--modules/markup/file_preview.go364
1 files changed, 364 insertions, 0 deletions
diff --git a/modules/markup/file_preview.go b/modules/markup/file_preview.go
new file mode 100644
index 0000000..49a5f1e
--- /dev/null
+++ b/modules/markup/file_preview.go
@@ -0,0 +1,364 @@
+// 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]]
+ 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(urlFull, 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
+}