Merge pull request 'Render inline file permalinks' (#2669) from Mai-Lapyst/forgejo:markup-add-filepreview into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/2669 Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
		
				commit
				
					
						2e744dc991
					
				
			
		
					 25 changed files with 577 additions and 4 deletions
				
			
		| 
						 | 
				
			
			@ -2338,6 +2338,8 @@ LEVEL = Info
 | 
			
		|||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 | 
			
		||||
;; Set the maximum number of characters in a mermaid source. (Set to -1 to disable limits)
 | 
			
		||||
;MERMAID_MAX_SOURCE_CHARACTERS = 5000
 | 
			
		||||
;; Set the maximum number of lines allowed for a filepreview. (Set to -1 to disable limits; set to 0 to disable the feature)
 | 
			
		||||
;FILEPREVIEW_MAX_LINES = 50
 | 
			
		||||
 | 
			
		||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 | 
			
		||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										323
									
								
								modules/markup/file_preview.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										323
									
								
								modules/markup/file_preview.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,323 @@
 | 
			
		|||
// 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
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -171,6 +171,7 @@ type processor func(ctx *RenderContext, node *html.Node)
 | 
			
		|||
var defaultProcessors = []processor{
 | 
			
		||||
	fullIssuePatternProcessor,
 | 
			
		||||
	comparePatternProcessor,
 | 
			
		||||
	filePreviewPatternProcessor,
 | 
			
		||||
	fullHashPatternProcessor,
 | 
			
		||||
	shortLinkProcessor,
 | 
			
		||||
	linkProcessor,
 | 
			
		||||
| 
						 | 
				
			
			@ -1054,6 +1055,47 @@ func comparePatternProcessor(ctx *RenderContext, node *html.Node) {
 | 
			
		|||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func filePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
 | 
			
		||||
	if ctx.Metas == nil {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	if DefaultProcessorHelper.GetRepoFileBlob == nil {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	next := node.NextSibling
 | 
			
		||||
	for node != nil && node != next {
 | 
			
		||||
		locale, ok := ctx.Ctx.Value(translation.ContextKey).(translation.Locale)
 | 
			
		||||
		if !ok {
 | 
			
		||||
			locale = translation.NewLocale("en-US")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		preview := NewFilePreview(ctx, node, locale)
 | 
			
		||||
		if preview == nil {
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		previewNode := preview.CreateHTML(locale)
 | 
			
		||||
 | 
			
		||||
		// Specialized version of replaceContent, so the parent paragraph element is not destroyed from our div
 | 
			
		||||
		before := node.Data[:preview.start]
 | 
			
		||||
		after := node.Data[preview.end:]
 | 
			
		||||
		node.Data = before
 | 
			
		||||
		nextSibling := node.NextSibling
 | 
			
		||||
		node.Parent.InsertBefore(&html.Node{
 | 
			
		||||
			Type: html.RawNode,
 | 
			
		||||
			Data: "</p>",
 | 
			
		||||
		}, nextSibling)
 | 
			
		||||
		node.Parent.InsertBefore(previewNode, nextSibling)
 | 
			
		||||
		node.Parent.InsertBefore(&html.Node{
 | 
			
		||||
			Type: html.RawNode,
 | 
			
		||||
			Data: "<p>" + after,
 | 
			
		||||
		}, nextSibling)
 | 
			
		||||
 | 
			
		||||
		node = node.NextSibling
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// emojiShortCodeProcessor for rendering text like :smile: into emoji
 | 
			
		||||
func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
 | 
			
		||||
	start := 0
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,9 +17,11 @@ import (
 | 
			
		|||
	"code.gitea.io/gitea/modules/markup"
 | 
			
		||||
	"code.gitea.io/gitea/modules/markup/markdown"
 | 
			
		||||
	"code.gitea.io/gitea/modules/setting"
 | 
			
		||||
	"code.gitea.io/gitea/modules/translation"
 | 
			
		||||
	"code.gitea.io/gitea/modules/util"
 | 
			
		||||
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
	"github.com/stretchr/testify/require"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var localMetas = map[string]string{
 | 
			
		||||
| 
						 | 
				
			
			@ -676,3 +678,68 @@ func TestIssue18471(t *testing.T) {
 | 
			
		|||
	assert.NoError(t, err)
 | 
			
		||||
	assert.Equal(t, "<a href=\"http://domain/org/repo/compare/783b039...da951ce\" class=\"compare\"><code class=\"nohighlight\">783b039...da951ce</code></a>", res.String())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestRender_FilePreview(t *testing.T) {
 | 
			
		||||
	setting.StaticRootPath = "../../"
 | 
			
		||||
	setting.Names = []string{"english"}
 | 
			
		||||
	setting.Langs = []string{"en-US"}
 | 
			
		||||
	translation.InitLocales(context.Background())
 | 
			
		||||
 | 
			
		||||
	setting.AppURL = markup.TestAppURL
 | 
			
		||||
	markup.Init(&markup.ProcessorHelper{
 | 
			
		||||
		GetRepoFileBlob: func(ctx context.Context, ownerName, repoName, commitSha, filePath string, language *string) (*git.Blob, error) {
 | 
			
		||||
			gitRepo, err := git.OpenRepository(git.DefaultContext, "./tests/repo/repo1_filepreview")
 | 
			
		||||
			require.NoError(t, err)
 | 
			
		||||
			defer gitRepo.Close()
 | 
			
		||||
 | 
			
		||||
			commit, err := gitRepo.GetCommit("HEAD")
 | 
			
		||||
			require.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
			blob, err := commit.GetBlobByPath("path/to/file.go")
 | 
			
		||||
			require.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
			return blob, nil
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	sha := "190d9492934af498c3f669d6a2431dc5459e5b20"
 | 
			
		||||
	commitFilePreview := util.URLJoin(markup.TestRepoURL, "src", "commit", sha, "path", "to", "file.go") + "#L2-L3"
 | 
			
		||||
 | 
			
		||||
	test := func(input, expected string) {
 | 
			
		||||
		buffer, err := markup.RenderString(&markup.RenderContext{
 | 
			
		||||
			Ctx:          git.DefaultContext,
 | 
			
		||||
			RelativePath: ".md",
 | 
			
		||||
			Metas:        localMetas,
 | 
			
		||||
		}, input)
 | 
			
		||||
		assert.NoError(t, err)
 | 
			
		||||
		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	test(
 | 
			
		||||
		commitFilePreview,
 | 
			
		||||
		`<p></p>`+
 | 
			
		||||
			`<div class="file-preview-box">`+
 | 
			
		||||
			`<div class="header">`+
 | 
			
		||||
			`<a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20/path/to/file.go#L2-L3" class="muted" rel="nofollow">path/to/file.go</a>`+
 | 
			
		||||
			`<span class="text small grey">`+
 | 
			
		||||
			`Lines 2 to 3 in <a href="http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20" class="text black" rel="nofollow">190d949</a>`+
 | 
			
		||||
			`</span>`+
 | 
			
		||||
			`</div>`+
 | 
			
		||||
			`<div class="ui table">`+
 | 
			
		||||
			`<table class="file-preview">`+
 | 
			
		||||
			`<tbody>`+
 | 
			
		||||
			`<tr>`+
 | 
			
		||||
			`<td class="lines-num"><span data-line-number="2"></span></td>`+
 | 
			
		||||
			`<td class="lines-code chroma"><code class="code-inner"><span class="nx">B</span>`+"\n"+`</code></td>`+
 | 
			
		||||
			`</tr>`+
 | 
			
		||||
			`<tr>`+
 | 
			
		||||
			`<td class="lines-num"><span data-line-number="3"></span></td>`+
 | 
			
		||||
			`<td class="lines-code chroma"><code class="code-inner"><span class="nx">C</span>`+"\n"+`</code></td>`+
 | 
			
		||||
			`</tr>`+
 | 
			
		||||
			`</tbody>`+
 | 
			
		||||
			`</table>`+
 | 
			
		||||
			`</div>`+
 | 
			
		||||
			`</div>`+
 | 
			
		||||
			`<p></p>`,
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -31,6 +31,7 @@ const (
 | 
			
		|||
 | 
			
		||||
type ProcessorHelper struct {
 | 
			
		||||
	IsUsernameMentionable func(ctx context.Context, username string) bool
 | 
			
		||||
	GetRepoFileBlob       func(ctx context.Context, ownerName, repoName, commitSha, filePath string, language *string) (*git.Blob, error)
 | 
			
		||||
 | 
			
		||||
	ElementDir string // the direction of the elements, eg: "ltr", "rtl", "auto", default to no direction attribute
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -113,6 +113,23 @@ func createDefaultPolicy() *bluemonday.Policy {
 | 
			
		|||
	// Allow 'color' and 'background-color' properties for the style attribute on text elements.
 | 
			
		||||
	policy.AllowStyles("color", "background-color").OnElements("span", "p")
 | 
			
		||||
 | 
			
		||||
	// Allow classes for file preview links...
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile("^(lines-num|lines-code chroma)$")).OnElements("td")
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile("^code-inner$")).OnElements("code")
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile("^file-preview-box$")).OnElements("div")
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile("^ui table$")).OnElements("div")
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile("^header$")).OnElements("div")
 | 
			
		||||
	policy.AllowAttrs("data-line-number").Matching(regexp.MustCompile("^[0-9]+$")).OnElements("span")
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile("^text small grey$")).OnElements("span")
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile("^file-preview*")).OnElements("table")
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile("^lines-escape$")).OnElements("td")
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile("^toggle-escape-button btn interact-bg$")).OnElements("button")
 | 
			
		||||
	policy.AllowAttrs("title").OnElements("button")
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile("^ambiguous-code-point$")).OnElements("span")
 | 
			
		||||
	policy.AllowAttrs("data-tooltip-content").OnElements("span")
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile("muted|(text black)")).OnElements("a")
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile("^ui warning message tw-text-left$")).OnElements("div")
 | 
			
		||||
 | 
			
		||||
	// Allow generally safe attributes
 | 
			
		||||
	generalSafeAttrs := []string{
 | 
			
		||||
		"abbr", "accept", "accept-charset",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										1
									
								
								modules/markup/tests/repo/repo1_filepreview/HEAD
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								modules/markup/tests/repo/repo1_filepreview/HEAD
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
ref: refs/heads/master
 | 
			
		||||
							
								
								
									
										6
									
								
								modules/markup/tests/repo/repo1_filepreview/config
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								modules/markup/tests/repo/repo1_filepreview/config
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
[core]
 | 
			
		||||
	repositoryformatversion = 0
 | 
			
		||||
	filemode = true
 | 
			
		||||
	bare = true
 | 
			
		||||
[remote "origin"]
 | 
			
		||||
	url = /home/mai/projects/codeark/forgejo/forgejo/modules/markup/tests/repo/repo1_filepreview/../../__test_repo
 | 
			
		||||
							
								
								
									
										1
									
								
								modules/markup/tests/repo/repo1_filepreview/description
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								modules/markup/tests/repo/repo1_filepreview/description
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
Unnamed repository; edit this file 'description' to name the repository.
 | 
			
		||||
							
								
								
									
										6
									
								
								modules/markup/tests/repo/repo1_filepreview/info/exclude
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								modules/markup/tests/repo/repo1_filepreview/info/exclude
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
# git ls-files --others --exclude-from=.git/info/exclude
 | 
			
		||||
# Lines that start with '#' are comments.
 | 
			
		||||
# For a project mostly in C, the following would be a good set of
 | 
			
		||||
# exclude patterns (uncomment them if you want to use them):
 | 
			
		||||
# *.[oa]
 | 
			
		||||
# *~
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
x+)JMU06e040031QHËÌIÕKÏghQºÂ/TX'·7潊ç·såË#3‹ô
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
190d9492934af498c3f669d6a2431dc5459e5b20
 | 
			
		||||
| 
						 | 
				
			
			@ -15,6 +15,7 @@ var (
 | 
			
		|||
	ExternalMarkupRenderers    []*MarkupRenderer
 | 
			
		||||
	ExternalSanitizerRules     []MarkupSanitizerRule
 | 
			
		||||
	MermaidMaxSourceCharacters int
 | 
			
		||||
	FilePreviewMaxLines        int
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
| 
						 | 
				
			
			@ -62,6 +63,7 @@ func loadMarkupFrom(rootCfg ConfigProvider) {
 | 
			
		|||
	mustMapSetting(rootCfg, "markdown", &Markdown)
 | 
			
		||||
 | 
			
		||||
	MermaidMaxSourceCharacters = rootCfg.Section("markup").Key("MERMAID_MAX_SOURCE_CHARACTERS").MustInt(5000)
 | 
			
		||||
	FilePreviewMaxLines = rootCfg.Section("markup").Key("FILEPREVIEW_MAX_LINES").MustInt(50)
 | 
			
		||||
	ExternalMarkupRenderers = make([]*MarkupRenderer, 0, 10)
 | 
			
		||||
	ExternalSanitizerRules = make([]MarkupSanitizerRule, 0, 10)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3725,3 +3725,8 @@ normal_file = Normal file
 | 
			
		|||
executable_file = Executable file
 | 
			
		||||
symbolic_link = Symbolic link
 | 
			
		||||
submodule = Submodule
 | 
			
		||||
 | 
			
		||||
[markup]
 | 
			
		||||
filepreview.line = Line %[1]d in %[2]s
 | 
			
		||||
filepreview.lines = Lines %[1]d to %[2]d in %[3]s
 | 
			
		||||
filepreview.truncated = Preview has been truncated
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,10 +5,18 @@ package markup
 | 
			
		|||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/models/perm/access"
 | 
			
		||||
	"code.gitea.io/gitea/models/repo"
 | 
			
		||||
	"code.gitea.io/gitea/models/unit"
 | 
			
		||||
	"code.gitea.io/gitea/models/user"
 | 
			
		||||
	"code.gitea.io/gitea/modules/git"
 | 
			
		||||
	"code.gitea.io/gitea/modules/gitrepo"
 | 
			
		||||
	"code.gitea.io/gitea/modules/log"
 | 
			
		||||
	"code.gitea.io/gitea/modules/markup"
 | 
			
		||||
	gitea_context "code.gitea.io/gitea/services/context"
 | 
			
		||||
	file_service "code.gitea.io/gitea/services/repository/files"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func ProcessorHelper() *markup.ProcessorHelper {
 | 
			
		||||
| 
						 | 
				
			
			@ -29,5 +37,51 @@ func ProcessorHelper() *markup.ProcessorHelper {
 | 
			
		|||
			// when using gitea context (web context), use user's visibility and user's permission to check
 | 
			
		||||
			return user.IsUserVisibleToViewer(giteaCtx, mentionedUser, giteaCtx.Doer)
 | 
			
		||||
		},
 | 
			
		||||
		GetRepoFileBlob: func(ctx context.Context, ownerName, repoName, commitSha, filePath string, language *string) (*git.Blob, error) {
 | 
			
		||||
			repo, err := repo.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return nil, err
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var user *user.User
 | 
			
		||||
 | 
			
		||||
			giteaCtx, ok := ctx.(*gitea_context.Context)
 | 
			
		||||
			if ok {
 | 
			
		||||
				user = giteaCtx.Doer
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			perms, err := access.GetUserRepoPermission(ctx, repo, user)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return nil, err
 | 
			
		||||
			}
 | 
			
		||||
			if !perms.CanRead(unit.TypeCode) {
 | 
			
		||||
				return nil, fmt.Errorf("cannot access repository code")
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			gitRepo, err := gitrepo.OpenRepository(ctx, repo)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return nil, err
 | 
			
		||||
			}
 | 
			
		||||
			defer gitRepo.Close()
 | 
			
		||||
 | 
			
		||||
			commit, err := gitRepo.GetCommit(commitSha)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return nil, err
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if language != nil {
 | 
			
		||||
				*language, err = file_service.TryGetContentLanguage(gitRepo, commitSha, filePath)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					log.Error("Unable to get file language for %-v:%s. Error: %v", repo, filePath, err)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			blob, err := commit.GetBlobByPath(filePath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return nil, err
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return blob, nil
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -40,6 +40,7 @@
 | 
			
		|||
@import "./markup/content.css";
 | 
			
		||||
@import "./markup/codecopy.css";
 | 
			
		||||
@import "./markup/asciicast.css";
 | 
			
		||||
@import "./markup/filepreview.css";
 | 
			
		||||
 | 
			
		||||
@import "./chroma/base.css";
 | 
			
		||||
@import "./codemirror/base.css";
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -451,7 +451,8 @@
 | 
			
		|||
  text-decoration: inherit;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.markup pre > code {
 | 
			
		||||
.markup pre > code,
 | 
			
		||||
.markup .file-preview code {
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  font-size: 100%;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										41
									
								
								web_src/css/markup/filepreview.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								web_src/css/markup/filepreview.css
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,41 @@
 | 
			
		|||
.markup table.file-preview {
 | 
			
		||||
  margin-bottom: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.markup table.file-preview td {
 | 
			
		||||
  padding: 0 10px !important;
 | 
			
		||||
  border: none !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.markup table.file-preview tr {
 | 
			
		||||
  border-top: none;
 | 
			
		||||
  background-color: inherit !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.markup .file-preview-box {
 | 
			
		||||
  margin-bottom: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.markup .file-preview-box .header {
 | 
			
		||||
  padding: .5rem;
 | 
			
		||||
  padding-left: 1rem;
 | 
			
		||||
  border: 1px solid var(--color-secondary);
 | 
			
		||||
  border-bottom: none;
 | 
			
		||||
  border-radius: 0.28571429rem 0.28571429rem 0 0;
 | 
			
		||||
  background: var(--color-box-header);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.markup .file-preview-box .warning {
 | 
			
		||||
  border-radius: 0;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  padding: .5rem .5rem .5rem 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.markup .file-preview-box .header > a {
 | 
			
		||||
  display: block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.markup .file-preview-box .table {
 | 
			
		||||
  margin-top: 0;
 | 
			
		||||
  border-radius: 0 0 0.28571429rem 0.28571429rem;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
.code-view .lines-num:hover {
 | 
			
		||||
.code-view .lines-num:hover,
 | 
			
		||||
.file-preview .lines-num:hover {
 | 
			
		||||
  color: var(--color-text-dark) !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,8 +7,8 @@ export function initUnicodeEscapeButton() {
 | 
			
		|||
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
 | 
			
		||||
    const fileContent = btn.closest('.file-content, .non-diff-file-content');
 | 
			
		||||
    const fileView = fileContent?.querySelectorAll('.file-code, .file-view');
 | 
			
		||||
    const fileContent = btn.closest('.file-content, .non-diff-file-content, .file-preview-box');
 | 
			
		||||
    const fileView = fileContent?.querySelectorAll('.file-code, .file-view, .file-preview');
 | 
			
		||||
    if (btn.matches('.escape-button')) {
 | 
			
		||||
      for (const el of fileView) el.classList.add('unicode-escaped');
 | 
			
		||||
      hideElem(btn);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue