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: "</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
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, "<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>`,
+	)
+}
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 0000000000..161d0bafc6
Binary files /dev/null and b/modules/markup/tests/repo/repo1_filepreview/objects/19/0d9492934af498c3f669d6a2431dc5459e5b20 differ
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 0000000000..adf64119a3
Binary files /dev/null and b/modules/markup/tests/repo/repo1_filepreview/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 differ
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/83/57a737d04385bb7f2ab59ff184be94756e7972 b/modules/markup/tests/repo/repo1_filepreview/objects/83/57a737d04385bb7f2ab59ff184be94756e7972
new file mode 100644
index 0000000000..1b87aa8b23
Binary files /dev/null and b/modules/markup/tests/repo/repo1_filepreview/objects/83/57a737d04385bb7f2ab59ff184be94756e7972 differ
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/84/22d40f12717e1ebd5cef2449f6c09d1f775969 b/modules/markup/tests/repo/repo1_filepreview/objects/84/22d40f12717e1ebd5cef2449f6c09d1f775969
new file mode 100644
index 0000000000..d38170a588
Binary files /dev/null and b/modules/markup/tests/repo/repo1_filepreview/objects/84/22d40f12717e1ebd5cef2449f6c09d1f775969 differ
diff --git a/modules/markup/tests/repo/repo1_filepreview/objects/d4/490327def9658be036d6a52c4417d84e74dd4c b/modules/markup/tests/repo/repo1_filepreview/objects/d4/490327def9658be036d6a52c4417d84e74dd4c
new file mode 100644
index 0000000000..fe37c11528
Binary files /dev/null and b/modules/markup/tests/repo/repo1_filepreview/objects/d4/490327def9658be036d6a52c4417d84e74dd4c differ
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 97dc28b795..fbe67c28b8 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -3728,3 +3728,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);