diff --git a/modules/markup/html.go b/modules/markup/html.go
index b7291823b5..2501f8062d 100644
--- a/modules/markup/html.go
+++ b/modules/markup/html.go
@@ -10,10 +10,12 @@ import (
 	"path"
 	"path/filepath"
 	"regexp"
+	"strconv"
 	"strings"
 	"sync"
 
 	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/modules/charset"
 	"code.gitea.io/gitea/modules/emoji"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/log"
@@ -61,6 +63,9 @@ var (
 
 	validLinksPattern = regexp.MustCompile(`^[a-z][\w-]+://`)
 
+	// filePreviewPattern matches "http://domain/org/repo/src/commit/COMMIT/filepath#L1-L2"
+	filePreviewPattern = regexp.MustCompile(`https?://((?:\S+/){3})src/commit/([0-9a-f]{7,64})/(\S+)#(L\d+(?:-L\d+)?)`)
+
 	// While this email regex is definitely not perfect and I'm sure you can come up
 	// with edge cases, it is still accepted by the CommonMark specification, as
 	// well as the HTML5 spec:
@@ -171,6 +176,7 @@ type processor func(ctx *RenderContext, node *html.Node)
 var defaultProcessors = []processor{
 	fullIssuePatternProcessor,
 	comparePatternProcessor,
+	filePreviewPatternProcessor,
 	fullHashPatternProcessor,
 	shortLinkProcessor,
 	linkProcessor,
@@ -1054,6 +1060,267 @@ func comparePatternProcessor(ctx *RenderContext, node *html.Node) {
 	}
 }
 
+func filePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
+	if ctx.Metas == nil {
+		return
+	}
+	if DefaultProcessorHelper.GetRepoFileContent == nil || DefaultProcessorHelper.GetLocale == nil {
+		return
+	}
+
+	next := node.NextSibling
+	for node != nil && node != next {
+		m := filePreviewPattern.FindStringSubmatchIndex(node.Data)
+		if m == nil {
+			return
+		}
+
+		// Ensure that every group (m[0]...m[9]) has a match
+		for i := 0; i < 10; i++ {
+			if m[i] == -1 {
+				return
+			}
+		}
+
+		urlFull := node.Data[m[0]:m[1]]
+
+		// Ensure that we only use links to local repositories
+		if !strings.HasPrefix(urlFull, setting.AppURL+setting.AppSubURL) {
+			return
+		}
+
+		projPath := node.Data[m[2]:m[3]]
+		projPath = strings.TrimSuffix(projPath, "/")
+
+		commitSha := node.Data[m[4]:m[5]]
+		filePath := node.Data[m[6]:m[7]]
+		hash := node.Data[m[8]:m[9]]
+
+		start := m[0]
+		end := m[1]
+
+		// If url ends in '.', it's very likely that it is not part of the
+		// actual url but used to finish a sentence.
+		if strings.HasSuffix(urlFull, ".") {
+			end--
+			urlFull = urlFull[:len(urlFull)-1]
+			hash = hash[:len(hash)-1]
+		}
+
+		projPathSegments := strings.Split(projPath, "/")
+		fileContent, err := DefaultProcessorHelper.GetRepoFileContent(
+			ctx.Ctx,
+			projPathSegments[len(projPathSegments)-2],
+			projPathSegments[len(projPathSegments)-1],
+			commitSha, filePath,
+		)
+		if err != nil {
+			return
+		}
+
+		lineSpecs := strings.Split(hash, "-")
+		lineCount := len(fileContent)
+
+		var subTitle string
+		var lineOffset int
+
+		if len(lineSpecs) == 1 {
+			line, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
+			if line < 1 || line > lineCount {
+				return
+			}
+
+			fileContent = fileContent[line-1 : line]
+			subTitle = "Line " + strconv.Itoa(line)
+
+			lineOffset = line - 1
+		} else {
+			startLine, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
+			endLine, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[1], "L"))
+
+			if startLine < 1 || endLine < 1 || startLine > lineCount || endLine > lineCount || endLine < startLine {
+				return
+			}
+
+			fileContent = fileContent[startLine-1 : endLine]
+			subTitle = "Lines " + strconv.Itoa(startLine) + " to " + strconv.Itoa(endLine)
+
+			lineOffset = startLine - 1
+		}
+
+		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(),
+		}
+
+		locale, err := DefaultProcessorHelper.GetLocale(ctx.Ctx)
+		if err != nil {
+			log.Error("Unable to get locale. Error: %v", err)
+			return
+		}
+
+		status := &charset.EscapeStatus{}
+		statuses := make([]*charset.EscapeStatus, len(fileContent))
+		for i, line := range fileContent {
+			statuses[i], fileContent[i] = charset.EscapeControlHTML(line, locale, charset.FileviewContext)
+			status = status.Or(statuses[i])
+		}
+
+		for idx, code := range fileContent {
+			tr := &html.Node{
+				Type: html.ElementNode,
+				Data: atom.Tr.String(),
+			}
+
+			lineNum := strconv.Itoa(lineOffset + idx + 1)
+
+			tdLinesnum := &html.Node{
+				Type: html.ElementNode,
+				Data: atom.Td.String(),
+				Attr: []html.Attribute{
+					{Key: "id", Val: "L" + lineNum},
+					{Key: "class", Val: "lines-num"},
+				},
+			}
+			spanLinesNum := &html.Node{
+				Type: html.ElementNode,
+				Data: atom.Span.String(),
+				Attr: []html.Attribute{
+					{Key: "id", Val: "L" + lineNum},
+					{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: "rel", Val: "L" + lineNum},
+					{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: urlFull},
+				{Key: "class", Val: "muted"},
+			},
+		}
+		afilepath.AppendChild(&html.Node{
+			Type: html.TextNode,
+			Data: 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.TextNode,
+			Data: subTitle + " in ",
+		})
+		psubtitle.AppendChild(createLink(urlFull[m[0]:m[5]], commitSha[0:7], "text black"))
+		header.AppendChild(psubtitle)
+
+		preview := &html.Node{
+			Type: html.ElementNode,
+			Data: atom.Div.String(),
+			Attr: []html.Attribute{{Key: "class", Val: "file-preview-box"}},
+		}
+		preview.AppendChild(header)
+		preview.AppendChild(twrapper)
+
+		// Specialized version of replaceContent, so the parent paragraph element is not destroyed from our div
+		before := node.Data[:start]
+		after := node.Data[end:]
+		node.Data = before
+		nextSibling := node.NextSibling
+		node.Parent.InsertBefore(&html.Node{
+			Type: html.RawNode,
+			Data: "</p>",
+		}, nextSibling)
+		node.Parent.InsertBefore(preview, 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 132955c019..652db13e5e 100644
--- a/modules/markup/html_test.go
+++ b/modules/markup/html_test.go
@@ -5,6 +5,7 @@ package markup_test
 
 import (
 	"context"
+	"html/template"
 	"io"
 	"os"
 	"strings"
@@ -13,10 +14,12 @@ import (
 	"code.gitea.io/gitea/models/unittest"
 	"code.gitea.io/gitea/modules/emoji"
 	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/highlight"
 	"code.gitea.io/gitea/modules/log"
 	"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"
@@ -673,3 +676,57 @@ 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.AppURL = markup.TestAppURL
+	markup.Init(&markup.ProcessorHelper{
+		GetRepoFileContent: func(ctx context.Context, ownerName, repoName, commitSha, filePath string) ([]template.HTML, error) {
+			buf := []byte("A\nB\nC\nD\n")
+			return highlight.PlainText(buf), nil
+		},
+		GetLocale: func(ctx context.Context) (translation.Locale, error) {
+			return translation.NewLocale("en-US"), nil
+		},
+	})
+
+	sha := "b6dd6210eaebc915fd5be5579c58cce4da2e2579"
+	commitFilePreview := util.URLJoin(markup.TestRepoURL, "src", "commit", sha, "path", "to", "file.go") + "#L1-L2"
+
+	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/b6dd6210eaebc915fd5be5579c58cce4da2e2579/path/to/file.go#L1-L2" class="muted" rel="nofollow">path/to/file.go</a>`+
+			`<span class="text small grey">`+
+			`Lines 1 to 2 in <a href="http://localhost:3000/gogits/gogs/src/commit/b6dd6210eaebc915fd5be5579c58cce4da2e2579" class="text black" rel="nofollow">b6dd621</a>`+
+			`</span>`+
+			`</div>`+
+			`<div class="ui table">`+
+			`<table class="file-preview">`+
+			`<tbody>`+
+			`<tr>`+
+			`<td id="user-content-L1" class="lines-num"><span id="user-content-L1" data-line-number="1"></span></td>`+
+			`<td rel="L1" class="lines-code chroma"><code class="code-inner">A`+"\n"+`</code></td>`+
+			`</tr>`+
+			`<tr>`+
+			`<td id="user-content-L2" class="lines-num"><span id="user-content-L2" data-line-number="2"></span></td>`+
+			`<td rel="L2" class="lines-code chroma"><code class="code-inner">B`+"\n"+`</code></td>`+
+			`</tr>`+
+			`</tbody>`+
+			`</table>`+
+			`</div>`+
+			`</div>`+
+			`<p></p>`,
+	)
+}
diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go
index 5a7adcc553..37d3fde58c 100644
--- a/modules/markup/renderer.go
+++ b/modules/markup/renderer.go
@@ -8,6 +8,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"html/template"
 	"io"
 	"net/url"
 	"path/filepath"
@@ -16,6 +17,7 @@ import (
 
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/translation"
 	"code.gitea.io/gitea/modules/util"
 
 	"github.com/yuin/goldmark/ast"
@@ -31,6 +33,8 @@ const (
 
 type ProcessorHelper struct {
 	IsUsernameMentionable func(ctx context.Context, username string) bool
+	GetRepoFileContent    func(ctx context.Context, ownerName, repoName, commitSha, filePath string) ([]template.HTML, error)
+	GetLocale             func(ctx context.Context) (translation.Locale, 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 ffc33c3b8e..73e17060a7 100644
--- a/modules/markup/sanitizer.go
+++ b/modules/markup/sanitizer.go
@@ -120,6 +120,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("rel").Matching(regexp.MustCompile("^L[0-9]+$")).OnElements("td")
+	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")
+
 	// Allow generally safe attributes
 	generalSafeAttrs := []string{
 		"abbr", "accept", "accept-charset",
diff --git a/services/markup/processorhelper.go b/services/markup/processorhelper.go
index a4378678a0..134b1b5152 100644
--- a/services/markup/processorhelper.go
+++ b/services/markup/processorhelper.go
@@ -5,10 +5,21 @@ package markup
 
 import (
 	"context"
+	"fmt"
+	"html/template"
+	"io"
 
+	"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/gitrepo"
+	"code.gitea.io/gitea/modules/highlight"
+	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/markup"
+	"code.gitea.io/gitea/modules/translation"
 	gitea_context "code.gitea.io/gitea/services/context"
+	file_service "code.gitea.io/gitea/services/repository/files"
 )
 
 func ProcessorHelper() *markup.ProcessorHelper {
@@ -29,5 +40,75 @@ 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)
 		},
+		GetRepoFileContent: func(ctx context.Context, ownerName, repoName, commitSha, filePath string) ([]template.HTML, 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
+			}
+
+			commit, err := gitRepo.GetCommit(commitSha)
+			if err != nil {
+				return nil, err
+			}
+
+			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
+			}
+
+			dataRc, err := blob.DataAsync()
+			if err != nil {
+				return nil, err
+			}
+			defer dataRc.Close()
+
+			buf, _ := io.ReadAll(dataRc)
+
+			fileContent, _, err := highlight.File(blob.Name(), language, buf)
+			if err != nil {
+				log.Error("highlight.File failed, fallback to plain text: %v", err)
+				fileContent = highlight.PlainText(buf)
+			}
+
+			return fileContent, nil
+		},
+		GetLocale: func(ctx context.Context) (translation.Locale, error) {
+			giteaCtx, ok := ctx.(*gitea_context.Context)
+			if ok {
+				return giteaCtx.Locale, nil
+			}
+
+			giteaBaseCtx, ok := ctx.(*gitea_context.Base)
+			if ok {
+				return giteaBaseCtx.Locale, nil
+			}
+
+			return nil, fmt.Errorf("could not retrieve locale from context")
+		},
 	}
 }
diff --git a/web_src/css/index.css b/web_src/css/index.css
index ab925a4aa0..8d2780ba42 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -30,6 +30,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..69360e2a70
--- /dev/null
+++ b/web_src/css/markup/filepreview.css
@@ -0,0 +1,35 @@
+.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 .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 1e5e51eac5..7780d6a263 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);