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)
|
;; Set the maximum number of characters in a mermaid source. (Set to -1 to disable limits)
|
||||||
;MERMAID_MAX_SOURCE_CHARACTERS = 5000
|
;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{
|
var defaultProcessors = []processor{
|
||||||
fullIssuePatternProcessor,
|
fullIssuePatternProcessor,
|
||||||
comparePatternProcessor,
|
comparePatternProcessor,
|
||||||
|
filePreviewPatternProcessor,
|
||||||
fullHashPatternProcessor,
|
fullHashPatternProcessor,
|
||||||
shortLinkProcessor,
|
shortLinkProcessor,
|
||||||
linkProcessor,
|
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
|
// emojiShortCodeProcessor for rendering text like :smile: into emoji
|
||||||
func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
|
func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
|
||||||
start := 0
|
start := 0
|
||||||
|
|
|
@ -17,9 +17,11 @@ import (
|
||||||
"code.gitea.io/gitea/modules/markup"
|
"code.gitea.io/gitea/modules/markup"
|
||||||
"code.gitea.io/gitea/modules/markup/markdown"
|
"code.gitea.io/gitea/modules/markup/markdown"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/translation"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
var localMetas = map[string]string{
|
var localMetas = map[string]string{
|
||||||
|
@ -676,3 +678,68 @@ func TestIssue18471(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
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())
|
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 {
|
type ProcessorHelper struct {
|
||||||
IsUsernameMentionable func(ctx context.Context, username string) bool
|
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
|
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.
|
// Allow 'color' and 'background-color' properties for the style attribute on text elements.
|
||||||
policy.AllowStyles("color", "background-color").OnElements("span", "p")
|
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
|
// Allow generally safe attributes
|
||||||
generalSafeAttrs := []string{
|
generalSafeAttrs := []string{
|
||||||
"abbr", "accept", "accept-charset",
|
"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
|
ExternalMarkupRenderers []*MarkupRenderer
|
||||||
ExternalSanitizerRules []MarkupSanitizerRule
|
ExternalSanitizerRules []MarkupSanitizerRule
|
||||||
MermaidMaxSourceCharacters int
|
MermaidMaxSourceCharacters int
|
||||||
|
FilePreviewMaxLines int
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -62,6 +63,7 @@ func loadMarkupFrom(rootCfg ConfigProvider) {
|
||||||
mustMapSetting(rootCfg, "markdown", &Markdown)
|
mustMapSetting(rootCfg, "markdown", &Markdown)
|
||||||
|
|
||||||
MermaidMaxSourceCharacters = rootCfg.Section("markup").Key("MERMAID_MAX_SOURCE_CHARACTERS").MustInt(5000)
|
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)
|
ExternalMarkupRenderers = make([]*MarkupRenderer, 0, 10)
|
||||||
ExternalSanitizerRules = make([]MarkupSanitizerRule, 0, 10)
|
ExternalSanitizerRules = make([]MarkupSanitizerRule, 0, 10)
|
||||||
|
|
||||||
|
|
|
@ -3725,3 +3725,8 @@ normal_file = Normal file
|
||||||
executable_file = Executable file
|
executable_file = Executable file
|
||||||
symbolic_link = Symbolic link
|
symbolic_link = Symbolic link
|
||||||
submodule = Submodule
|
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 (
|
import (
|
||||||
"context"
|
"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/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"
|
"code.gitea.io/gitea/modules/markup"
|
||||||
gitea_context "code.gitea.io/gitea/services/context"
|
gitea_context "code.gitea.io/gitea/services/context"
|
||||||
|
file_service "code.gitea.io/gitea/services/repository/files"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ProcessorHelper() *markup.ProcessorHelper {
|
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
|
// when using gitea context (web context), use user's visibility and user's permission to check
|
||||||
return user.IsUserVisibleToViewer(giteaCtx, mentionedUser, giteaCtx.Doer)
|
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/content.css";
|
||||||
@import "./markup/codecopy.css";
|
@import "./markup/codecopy.css";
|
||||||
@import "./markup/asciicast.css";
|
@import "./markup/asciicast.css";
|
||||||
|
@import "./markup/filepreview.css";
|
||||||
|
|
||||||
@import "./chroma/base.css";
|
@import "./chroma/base.css";
|
||||||
@import "./codemirror/base.css";
|
@import "./codemirror/base.css";
|
||||||
|
|
|
@ -451,7 +451,8 @@
|
||||||
text-decoration: inherit;
|
text-decoration: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markup pre > code {
|
.markup pre > code,
|
||||||
|
.markup .file-preview code {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 100%;
|
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;
|
color: var(--color-text-dark) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,8 @@ export function initUnicodeEscapeButton() {
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const fileContent = btn.closest('.file-content, .non-diff-file-content');
|
const fileContent = btn.closest('.file-content, .non-diff-file-content, .file-preview-box');
|
||||||
const fileView = fileContent?.querySelectorAll('.file-code, .file-view');
|
const fileView = fileContent?.querySelectorAll('.file-code, .file-view, .file-preview');
|
||||||
if (btn.matches('.escape-button')) {
|
if (btn.matches('.escape-button')) {
|
||||||
for (const el of fileView) el.classList.add('unicode-escaped');
|
for (const el of fileView) el.classList.add('unicode-escaped');
|
||||||
hideElem(btn);
|
hideElem(btn);
|
||||||
|
|
Loading…
Reference in a new issue