From 22aedc6c96806c7068c36e9beb2825e7f0bedc83 Mon Sep 17 00:00:00 2001
From: Mai-Lapyst
Date: Fri, 15 Mar 2024 13:44:42 +0100
Subject: [PATCH] [v7.0/forgejo] Render inline file permalinks
Backport: https://codeberg.org/forgejo/forgejo/pulls/2669
(cherry picked from commit 1d3240887c519a04c13bcd7e852c6d6ad1cb00b5)
(cherry picked from commit 781a37fbe18c223763f51968862f1c8f61e7e260)
(cherry picked from commit 8309f008c2721e313e1949ce42ed410e844c16e7)
(cherry picked from commit fae8d9f70d31704af91cbf37bcefcc4772830695)
(cherry picked from commit 6721cba75b4997448b618a4b00ef25f142924de0)
(cherry picked from commit 562e5cdf324597882b7e6971be1b9a148bbc7839)
(cherry picked from commit d789d33229b3998bb33f1505d122504c8039f23d)
(cherry picked from commit 8218e80bfc3a1f9ba02ce60f1acafdc0e57c5ae0)
(cherry picked from commit 10bca456a9140519e95559aa7bac2221e1156c5b)
(cherry picked from commit db6f6281fcf568ae8e35330a4a93c9be1cb46efd)
(cherry picked from commit ed8e8a792e75b930074cd3cf1bab580a09ff8485)
(cherry picked from commit d6428f92ce7ce67d127cbd5bb4977aa92abf071c)
(cherry picked from commit 069d87b80f909e91626249afbb240a1df339a8fd)
(cherry picked from commit 2b6546adc954d450a9c6befccd407ce2ca1636a0)
(cherry picked from commit 4c7cb0a5d20e8973b03e35d91119cf917eed125e)
(cherry picked from commit 7e0014dd1391e123d95f2537c3b2165fef7122ef)
(cherry picked from commit 16a8658878a2656cb131453b728b65a89271f11f)
(cherry picked from commit 6e98bacbbd3c089b2ccfa725c58184f4dfe5e7fe)
---
custom/conf/app.example.ini | 2 +
modules/markup/file_preview.go | 323 ++++++++++++++++++
modules/markup/html.go | 42 +++
modules/markup/html_test.go | 67 ++++
modules/markup/renderer.go | 1 +
modules/markup/sanitizer.go | 17 +
.../markup/tests/repo/repo1_filepreview/HEAD | 1 +
.../tests/repo/repo1_filepreview/config | 6 +
.../tests/repo/repo1_filepreview/description | 1 +
.../tests/repo/repo1_filepreview/info/exclude | 6 +
.../19/0d9492934af498c3f669d6a2431dc5459e5b20 | Bin 0 -> 120 bytes
.../4b/825dc642cb6eb9a060e54bf8d69288fbee4904 | Bin 0 -> 15 bytes
.../83/57a737d04385bb7f2ab59ff184be94756e7972 | Bin 0 -> 44 bytes
.../84/22d40f12717e1ebd5cef2449f6c09d1f775969 | Bin 0 -> 23 bytes
.../d4/490327def9658be036d6a52c4417d84e74dd4c | Bin 0 -> 46 bytes
.../ee/2b1253d9cf407796e2e724926cbe3a974b214d | 1 +
.../repo/repo1_filepreview/refs/heads/master | 1 +
modules/setting/markup.go | 2 +
options/locale/locale_en-US.ini | 5 +
services/markup/processorhelper.go | 54 +++
web_src/css/index.css | 1 +
web_src/css/markup/content.css | 3 +-
web_src/css/markup/filepreview.css | 41 +++
web_src/css/repo/linebutton.css | 3 +-
web_src/js/features/repo-unicode-escape.js | 4 +-
25 files changed, 577 insertions(+), 4 deletions(-)
create mode 100644 modules/markup/file_preview.go
create mode 100644 modules/markup/tests/repo/repo1_filepreview/HEAD
create mode 100644 modules/markup/tests/repo/repo1_filepreview/config
create mode 100644 modules/markup/tests/repo/repo1_filepreview/description
create mode 100644 modules/markup/tests/repo/repo1_filepreview/info/exclude
create mode 100644 modules/markup/tests/repo/repo1_filepreview/objects/19/0d9492934af498c3f669d6a2431dc5459e5b20
create mode 100644 modules/markup/tests/repo/repo1_filepreview/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904
create mode 100644 modules/markup/tests/repo/repo1_filepreview/objects/83/57a737d04385bb7f2ab59ff184be94756e7972
create mode 100644 modules/markup/tests/repo/repo1_filepreview/objects/84/22d40f12717e1ebd5cef2449f6c09d1f775969
create mode 100644 modules/markup/tests/repo/repo1_filepreview/objects/d4/490327def9658be036d6a52c4417d84e74dd4c
create mode 100644 modules/markup/tests/repo/repo1_filepreview/objects/ee/2b1253d9cf407796e2e724926cbe3a974b214d
create mode 100644 modules/markup/tests/repo/repo1_filepreview/refs/heads/master
create mode 100644 web_src/css/markup/filepreview.css
diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index b47e5fad0c..74999b5bb3 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/modules/markup/file_preview.go b/modules/markup/file_preview.go
new file mode 100644
index 0000000000..95c94e0c14
--- /dev/null
+++ b/modules/markup/file_preview.go
@@ -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
+}
diff --git a/modules/markup/html.go b/modules/markup/html.go
index 0cfd8be590..5ec7484eb4 100644
--- a/modules/markup/html.go
+++ b/modules/markup/html.go
@@ -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: "
",
+ }, nextSibling)
+ node.Parent.InsertBefore(previewNode, nextSibling)
+ node.Parent.InsertBefore(&html.Node{
+ Type: html.RawNode,
+ Data: "" + after,
+ }, nextSibling)
+
+ node = node.NextSibling
+ }
+}
+
// emojiShortCodeProcessor for rendering text like :smile: into emoji
func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
start := 0
diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go
index 1db6952bed..61a3edd6b3 100644
--- a/modules/markup/html_test.go
+++ b/modules/markup/html_test.go
@@ -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, "783b039...da951ce
", 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,
+ `
`+
+ ``+
+ ``+
+ `
`+
+ `
`+
+ ``+
+ ``+
+ ` | `+
+ `B`+"\n"+` | `+
+ `
`+
+ ``+
+ ` | `+
+ `C`+"\n"+` | `+
+ `
`+
+ ``+
+ `
`+
+ `
`+
+ `
`+
+ ``,
+ )
+}
diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go
index 0f0bf55740..f1beee964a 100644
--- a/modules/markup/renderer.go
+++ b/modules/markup/renderer.go
@@ -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
}
diff --git a/modules/markup/sanitizer.go b/modules/markup/sanitizer.go
index 79a2ba0dfb..cdbb1f7d97 100644
--- a/modules/markup/sanitizer.go
+++ b/modules/markup/sanitizer.go
@@ -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",
diff --git a/modules/markup/tests/repo/repo1_filepreview/HEAD b/modules/markup/tests/repo/repo1_filepreview/HEAD
new file mode 100644
index 0000000000..cb089cd89a
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/modules/markup/tests/repo/repo1_filepreview/config b/modules/markup/tests/repo/repo1_filepreview/config
new file mode 100644
index 0000000000..42cc799c8d
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/config
@@ -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
diff --git a/modules/markup/tests/repo/repo1_filepreview/description b/modules/markup/tests/repo/repo1_filepreview/description
new file mode 100644
index 0000000000..498b267a8c
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/modules/markup/tests/repo/repo1_filepreview/info/exclude b/modules/markup/tests/repo/repo1_filepreview/info/exclude
new file mode 100644
index 0000000000..a5196d1be8
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/info/exclude
@@ -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]
+# *~
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/19/0d9492934af498c3f669d6a2431dc5459e5b20 b/modules/markup/tests/repo/repo1_filepreview/objects/19/0d9492934af498c3f669d6a2431dc5459e5b20
new file mode 100644
index 0000000000000000000000000000000000000000..161d0bafc6731f5fe0b3b3c29ffe5463b056e840
GIT binary patch
literal 120
zcmV-;0Ehp00hNtO4#F@D06FIsz9S(!b&^)95RZTdgxH8kluC)q`&oX#X-+d!)@7*%
z6w=O`DhTt0gHNKjDTeW?I7Ep#_`*y{M%Kh4TwLDlzBagYZ3Of7#i>`*Lw&yTqskE|
a(I?AD9`;CxuKZr6|5@&=-P{|T!ZCX0g*&(a
literal 0
HcmV?d00001
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 b/modules/markup/tests/repo/repo1_filepreview/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904
new file mode 100644
index 0000000000000000000000000000000000000000..adf64119a33d7621aeeaa505d30adb58afaa5559
GIT binary patch
literal 15
Wcmb)%hIiUR!8gx4luvu~TxC+uKC9{8ioO8p9WH2!R0)>Lak_?9C@a5(goLhI-Yi*tXv1Q+s(!9zd00ff{
EldH%Sg8%>k
literal 0
HcmV?d00001
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/ee/2b1253d9cf407796e2e724926cbe3a974b214d b/modules/markup/tests/repo/repo1_filepreview/objects/ee/2b1253d9cf407796e2e724926cbe3a974b214d
new file mode 100644
index 0000000000..e13ca647db
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/objects/ee/2b1253d9cf407796e2e724926cbe3a974b214d
@@ -0,0 +1 @@
+x+)JMU06e040031QHËÌIÕKÏghQºÂ/TX'·7潊ç·såË#3‹ô
\ No newline at end of file
diff --git a/modules/markup/tests/repo/repo1_filepreview/refs/heads/master b/modules/markup/tests/repo/repo1_filepreview/refs/heads/master
new file mode 100644
index 0000000000..49c348b41c
--- /dev/null
+++ b/modules/markup/tests/repo/repo1_filepreview/refs/heads/master
@@ -0,0 +1 @@
+190d9492934af498c3f669d6a2431dc5459e5b20
diff --git a/modules/setting/markup.go b/modules/setting/markup.go
index 6c2246342b..e893c1c2f1 100644
--- a/modules/setting/markup.go
+++ b/modules/setting/markup.go
@@ -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)
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index b06e864869..63fa5997bb 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -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
diff --git a/services/markup/processorhelper.go b/services/markup/processorhelper.go
index a4378678a0..40bf1d65da 100644
--- a/services/markup/processorhelper.go
+++ b/services/markup/processorhelper.go
@@ -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
+ },
}
}
diff --git a/web_src/css/index.css b/web_src/css/index.css
index aa3f6ac48e..224d3d23ab 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -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";
diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css
index 5eeef078a5..430b4802d6 100644
--- a/web_src/css/markup/content.css
+++ b/web_src/css/markup/content.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%;
diff --git a/web_src/css/markup/filepreview.css b/web_src/css/markup/filepreview.css
new file mode 100644
index 0000000000..d2ec16ea8b
--- /dev/null
+++ b/web_src/css/markup/filepreview.css
@@ -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;
+}
diff --git a/web_src/css/repo/linebutton.css b/web_src/css/repo/linebutton.css
index e99d0399d1..d32899a06b 100644
--- a/web_src/css/repo/linebutton.css
+++ b/web_src/css/repo/linebutton.css
@@ -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;
}
diff --git a/web_src/js/features/repo-unicode-escape.js b/web_src/js/features/repo-unicode-escape.js
index d878532001..9f0c745223 100644
--- a/web_src/js/features/repo-unicode-escape.js
+++ b/web_src/js/features/repo-unicode-escape.js
@@ -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);