diff --git a/modules/charset/escape_test.go b/modules/charset/escape_test.go
index a353ced631..9d796a0c18 100644
--- a/modules/charset/escape_test.go
+++ b/modules/charset/escape_test.go
@@ -4,6 +4,7 @@
 package charset
 
 import (
+	"regexp"
 	"strings"
 	"testing"
 
@@ -156,13 +157,16 @@ func TestEscapeControlReader(t *testing.T) {
 		tests = append(tests, test)
 	}
 
+	re := regexp.MustCompile(`repo.ambiguous_character:\d+,\d+`) // simplify the output for the tests, remove the translation variants
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			output := &strings.Builder{}
 			status, err := EscapeControlReader(strings.NewReader(tt.text), output, &translation.MockLocale{})
 			assert.NoError(t, err)
 			assert.Equal(t, tt.status, *status)
-			assert.Equal(t, tt.result, output.String())
+			outStr := output.String()
+			outStr = re.ReplaceAllString(outStr, "repo.ambiguous_character")
+			assert.Equal(t, tt.result, outStr)
 		})
 	}
 }
diff --git a/modules/csv/csv_test.go b/modules/csv/csv_test.go
index f6e782a5a4..3ddb47acbb 100644
--- a/modules/csv/csv_test.go
+++ b/modules/csv/csv_test.go
@@ -561,14 +561,14 @@ func TestFormatError(t *testing.T) {
 			err: &csv.ParseError{
 				Err: csv.ErrFieldCount,
 			},
-			expectedMessage: "repo.error.csv.invalid_field_count",
+			expectedMessage: "repo.error.csv.invalid_field_count:0",
 			expectsError:    false,
 		},
 		{
 			err: &csv.ParseError{
 				Err: csv.ErrBareQuote,
 			},
-			expectedMessage: "repo.error.csv.unexpected",
+			expectedMessage: "repo.error.csv.unexpected:0,0",
 			expectsError:    false,
 		},
 		{
diff --git a/modules/indexer/code/search.go b/modules/indexer/code/search.go
index 5f35e8073b..74c957dde6 100644
--- a/modules/indexer/code/search.go
+++ b/modules/indexer/code/search.go
@@ -22,7 +22,7 @@ type Result struct {
 	UpdatedUnix timeutil.TimeStamp
 	Language    string
 	Color       string
-	Lines       []ResultLine
+	Lines       []*ResultLine
 }
 
 type ResultLine struct {
@@ -70,16 +70,18 @@ func writeStrings(buf *bytes.Buffer, strs ...string) error {
 	return nil
 }
 
-func HighlightSearchResultCode(filename string, lineNums []int, code string) []ResultLine {
+func HighlightSearchResultCode(filename, language string, lineNums []int, code string) []*ResultLine {
 	// we should highlight the whole code block first, otherwise it doesn't work well with multiple line highlighting
-	hl, _ := highlight.Code(filename, "", code)
+	hl, _ := highlight.Code(filename, language, code)
 	highlightedLines := strings.Split(string(hl), "\n")
 
 	// The lineNums outputted by highlight.Code might not match the original lineNums, because "highlight" removes the last `\n`
-	lines := make([]ResultLine, min(len(highlightedLines), len(lineNums)))
+	lines := make([]*ResultLine, min(len(highlightedLines), len(lineNums)))
 	for i := 0; i < len(lines); i++ {
-		lines[i].Num = lineNums[i]
-		lines[i].FormattedContent = template.HTML(highlightedLines[i])
+		lines[i] = &ResultLine{
+			Num:              lineNums[i],
+			FormattedContent: template.HTML(highlightedLines[i]),
+		}
 	}
 	return lines
 }
@@ -122,7 +124,7 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res
 		UpdatedUnix: result.UpdatedUnix,
 		Language:    result.Language,
 		Color:       result.Color,
-		Lines:       HighlightSearchResultCode(result.Filename, lineNums, formattedLinesBuffer.String()),
+		Lines:       HighlightSearchResultCode(result.Filename, result.Language, lineNums, formattedLinesBuffer.String()),
 	}, nil
 }
 
diff --git a/modules/markup/html.go b/modules/markup/html.go
index 21bd6206e0..56aa1cb49c 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,
+	codePreviewPatternProcessor,
 	fullHashPatternProcessor,
 	shortLinkProcessor,
 	linkProcessor,
diff --git a/modules/markup/html_codepreview.go b/modules/markup/html_codepreview.go
new file mode 100644
index 0000000000..d9da24ea34
--- /dev/null
+++ b/modules/markup/html_codepreview.go
@@ -0,0 +1,92 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+	"html/template"
+	"net/url"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"code.gitea.io/gitea/modules/httplib"
+	"code.gitea.io/gitea/modules/log"
+
+	"golang.org/x/net/html"
+)
+
+// codePreviewPattern matches "http://domain/.../{owner}/{repo}/src/commit/{commit}/{filepath}#L10-L20"
+var codePreviewPattern = regexp.MustCompile(`https?://\S+/([^\s/]+)/([^\s/]+)/src/commit/([0-9a-f]{7,64})(/\S+)#(L\d+(-L\d+)?)`)
+
+type RenderCodePreviewOptions struct {
+	FullURL   string
+	OwnerName string
+	RepoName  string
+	CommitID  string
+	FilePath  string
+
+	LineStart, LineStop int
+}
+
+func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosStop int, htm template.HTML, err error) {
+	m := codePreviewPattern.FindStringSubmatchIndex(node.Data)
+	if m == nil {
+		return 0, 0, "", nil
+	}
+
+	opts := RenderCodePreviewOptions{
+		FullURL:   node.Data[m[0]:m[1]],
+		OwnerName: node.Data[m[2]:m[3]],
+		RepoName:  node.Data[m[4]:m[5]],
+		CommitID:  node.Data[m[6]:m[7]],
+		FilePath:  node.Data[m[8]:m[9]],
+	}
+	if !httplib.IsCurrentGiteaSiteURL(opts.FullURL) {
+		return 0, 0, "", nil
+	}
+	u, err := url.Parse(opts.FilePath)
+	if err != nil {
+		return 0, 0, "", err
+	}
+	opts.FilePath = strings.TrimPrefix(u.Path, "/")
+
+	lineStartStr, lineStopStr, _ := strings.Cut(node.Data[m[10]:m[11]], "-")
+	lineStart, _ := strconv.Atoi(strings.TrimPrefix(lineStartStr, "L"))
+	lineStop, _ := strconv.Atoi(strings.TrimPrefix(lineStopStr, "L"))
+	opts.LineStart, opts.LineStop = lineStart, lineStop
+	h, err := DefaultProcessorHelper.RenderRepoFileCodePreview(ctx.Ctx, opts)
+	return m[0], m[1], h, err
+}
+
+func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
+	for node != nil {
+		if node.Type != html.TextNode {
+			node = node.NextSibling
+			continue
+		}
+		urlPosStart, urlPosEnd, h, err := renderCodeBlock(ctx, node)
+		if err != nil || h == "" {
+			if err != nil {
+				log.Error("Unable to render code preview: %v", err)
+			}
+			node = node.NextSibling
+			continue
+		}
+		next := node.NextSibling
+		textBefore := node.Data[:urlPosStart]
+		textAfter := node.Data[urlPosEnd:]
+		// "textBefore" could be empty if there is only a URL in the text node, then an empty node (p, or li) will be left here.
+		// However, the empty node can't be simply removed, because:
+		// 1. the following processors will still try to access it (need to double-check undefined behaviors)
+		// 2. the new node is inserted as "<p>{TextBefore}<div NewNode/>{TextAfter}</p>" (the parent could also be "li")
+		//    then it is resolved as: "<p>{TextBefore}</p><div NewNode/><p>{TextAfter}</p>",
+		//    so unless it could correctly replace the parent "p/li" node, it is very difficult to eliminate the "TextBefore" empty node.
+		node.Data = textBefore
+		node.Parent.InsertBefore(&html.Node{Type: html.RawNode, Data: string(h)}, next)
+		if textAfter != "" {
+			node.Parent.InsertBefore(&html.Node{Type: html.TextNode, Data: textAfter}, next)
+		}
+		node = next
+	}
+}
diff --git a/modules/markup/html_codepreview_test.go b/modules/markup/html_codepreview_test.go
new file mode 100644
index 0000000000..d33630d040
--- /dev/null
+++ b/modules/markup/html_codepreview_test.go
@@ -0,0 +1,34 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup_test
+
+import (
+	"context"
+	"html/template"
+	"strings"
+	"testing"
+
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/markup"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRenderCodePreview(t *testing.T) {
+	markup.Init(&markup.ProcessorHelper{
+		RenderRepoFileCodePreview: func(ctx context.Context, opts markup.RenderCodePreviewOptions) (template.HTML, error) {
+			return "<div>code preview</div>", nil
+		},
+	})
+	test := func(input, expected string) {
+		buffer, err := markup.RenderString(&markup.RenderContext{
+			Ctx:  git.DefaultContext,
+			Type: "markdown",
+		}, input)
+		assert.NoError(t, err)
+		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+	}
+	test("http://localhost:3000/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", "<p><div>code preview</div></p>")
+	test("http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", `<p><a href="http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20" rel="nofollow">http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20</a></p>`)
+}
diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go
index 0f0bf55740..005fcc278b 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"
@@ -33,6 +34,8 @@ type ProcessorHelper struct {
 	IsUsernameMentionable func(ctx context.Context, username string) bool
 
 	ElementDir string // the direction of the elements, eg: "ltr", "rtl", "auto", default to no direction attribute
+
+	RenderRepoFileCodePreview func(ctx context.Context, options RenderCodePreviewOptions) (template.HTML, error)
 }
 
 var DefaultProcessorHelper ProcessorHelper
diff --git a/modules/markup/sanitizer.go b/modules/markup/sanitizer.go
index 79a2ba0dfb..77fbdf4520 100644
--- a/modules/markup/sanitizer.go
+++ b/modules/markup/sanitizer.go
@@ -60,6 +60,21 @@ func createDefaultPolicy() *bluemonday.Policy {
 	// For JS code copy and Mermaid loading state
 	policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).OnElements("pre")
 
+	// For code preview
+	policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-preview-[-\w]+( file-content)?$`)).Globally()
+	policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-num$`)).OnElements("td")
+	policy.AllowAttrs("data-line-number").OnElements("span")
+	policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-code chroma$`)).OnElements("td")
+	policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-inner$`)).OnElements("code")
+
+	// For code preview (unicode escape)
+	policy.AllowAttrs("class").Matching(regexp.MustCompile(`^file-view( unicode-escaped)?$`)).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("a") // don't use button, button might submit a form
+	policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(ambiguous-code-point|escaped-code-point|broken-code-point)$`)).OnElements("span")
+	policy.AllowAttrs("class").Matching(regexp.MustCompile(`^char$`)).OnElements("span")
+	policy.AllowAttrs("data-tooltip-content", "data-escaped").OnElements("span")
+
 	// For color preview
 	policy.AllowAttrs("class").Matching(regexp.MustCompile(`^color-preview$`)).OnElements("span")
 
diff --git a/modules/translation/mock.go b/modules/translation/mock.go
index 18fbc1044a..f457271ea5 100644
--- a/modules/translation/mock.go
+++ b/modules/translation/mock.go
@@ -6,6 +6,7 @@ package translation
 import (
 	"fmt"
 	"html/template"
+	"strings"
 )
 
 // MockLocale provides a mocked locale without any translations
@@ -19,18 +20,25 @@ func (l MockLocale) Language() string {
 	return "en"
 }
 
-func (l MockLocale) TrString(s string, _ ...any) string {
-	return s
+func (l MockLocale) TrString(s string, args ...any) string {
+	return sprintAny(s, args...)
 }
 
-func (l MockLocale) Tr(s string, a ...any) template.HTML {
-	return template.HTML(s)
+func (l MockLocale) Tr(s string, args ...any) template.HTML {
+	return template.HTML(sprintAny(s, args...))
 }
 
 func (l MockLocale) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
-	return template.HTML(key1)
+	return template.HTML(sprintAny(key1, args...))
 }
 
 func (l MockLocale) PrettyNumber(v any) string {
 	return fmt.Sprint(v)
 }
+
+func sprintAny(s string, args ...any) string {
+	if len(args) == 0 {
+		return s
+	}
+	return s + ":" + fmt.Sprintf(strings.Repeat(",%v", len(args))[1:], args...)
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 39b9855186..0a3d12d7a4 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1233,6 +1233,8 @@ file_view_rendered = View Rendered
 file_view_raw = View Raw
 file_permalink = Permalink
 file_too_large = The file is too large to be shown.
+code_preview_line_from_to = Lines %[1]d to %[2]d in %[3]s
+code_preview_line_in = Line %[1]d in %[2]s
 invisible_runes_header = `This file contains invisible Unicode characters`
 invisible_runes_description = `This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.`
 ambiguous_runes_header = `This file contains ambiguous Unicode characters`
diff --git a/routers/web/repo/search.go b/routers/web/repo/search.go
index 9d65427b8f..46f0208453 100644
--- a/routers/web/repo/search.go
+++ b/routers/web/repo/search.go
@@ -81,7 +81,7 @@ func Search(ctx *context.Context) {
 				// UpdatedUnix: not supported yet
 				// Language:    not supported yet
 				// Color:       not supported yet
-				Lines: code_indexer.HighlightSearchResultCode(r.Filename, r.LineNumbers, strings.Join(r.LineCodes, "\n")),
+				Lines: code_indexer.HighlightSearchResultCode(r.Filename, "", r.LineNumbers, strings.Join(r.LineCodes, "\n")),
 			})
 		}
 	}
diff --git a/routers/web/repo/wiki_test.go b/routers/web/repo/wiki_test.go
index 52e216e6a0..8b5207f9d9 100644
--- a/routers/web/repo/wiki_test.go
+++ b/routers/web/repo/wiki_test.go
@@ -145,7 +145,7 @@ func TestNewWikiPost_ReservedName(t *testing.T) {
 	})
 	NewWikiPost(ctx)
 	assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
-	assert.EqualValues(t, ctx.Tr("repo.wiki.reserved_page"), ctx.Flash.ErrorMsg)
+	assert.EqualValues(t, ctx.Tr("repo.wiki.reserved_page", "_edit"), ctx.Flash.ErrorMsg)
 	assertWikiNotExists(t, ctx.Repo.Repository, "_edit")
 }
 
diff --git a/services/contexttest/context_tests.go b/services/contexttest/context_tests.go
index d3e6de7efe..3064c56590 100644
--- a/services/contexttest/context_tests.go
+++ b/services/contexttest/context_tests.go
@@ -63,6 +63,7 @@ func MockContext(t *testing.T, reqPath string, opts ...MockContextOption) (*cont
 	base.Locale = &translation.MockLocale{}
 
 	ctx := context.NewWebContext(base, opt.Render, nil)
+	ctx.AppendContextValue(context.WebContextKey, ctx)
 	ctx.PageData = map[string]any{}
 	ctx.Data["PageStartTime"] = time.Now()
 	chiCtx := chi.NewRouteContext()
diff --git a/services/markup/main_test.go b/services/markup/main_test.go
index 89fe3e7e34..5553ebc058 100644
--- a/services/markup/main_test.go
+++ b/services/markup/main_test.go
@@ -11,6 +11,6 @@ import (
 
 func TestMain(m *testing.M) {
 	unittest.MainTest(m, &unittest.TestOptions{
-		FixtureFiles: []string{"user.yml"},
+		FixtureFiles: []string{"user.yml", "repository.yml", "access.yml", "repo_unit.yml"},
 	})
 }
diff --git a/services/markup/processorhelper.go b/services/markup/processorhelper.go
index a4378678a0..68487fb8db 100644
--- a/services/markup/processorhelper.go
+++ b/services/markup/processorhelper.go
@@ -14,6 +14,8 @@ import (
 func ProcessorHelper() *markup.ProcessorHelper {
 	return &markup.ProcessorHelper{
 		ElementDir: "auto", // set dir="auto" for necessary (eg: <p>, <h?>, etc) tags
+
+		RenderRepoFileCodePreview: renderRepoFileCodePreview,
 		IsUsernameMentionable: func(ctx context.Context, username string) bool {
 			mentionedUser, err := user.GetUserByName(ctx, username)
 			if err != nil {
diff --git a/services/markup/processorhelper_codepreview.go b/services/markup/processorhelper_codepreview.go
new file mode 100644
index 0000000000..ef95046128
--- /dev/null
+++ b/services/markup/processorhelper_codepreview.go
@@ -0,0 +1,117 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+	"bufio"
+	"context"
+	"fmt"
+	"html/template"
+	"strings"
+
+	"code.gitea.io/gitea/models/perm/access"
+	"code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unit"
+	"code.gitea.io/gitea/modules/charset"
+	"code.gitea.io/gitea/modules/gitrepo"
+	"code.gitea.io/gitea/modules/indexer/code"
+	"code.gitea.io/gitea/modules/markup"
+	"code.gitea.io/gitea/modules/setting"
+	gitea_context "code.gitea.io/gitea/services/context"
+	"code.gitea.io/gitea/services/repository/files"
+)
+
+func renderRepoFileCodePreview(ctx context.Context, opts markup.RenderCodePreviewOptions) (template.HTML, error) {
+	opts.LineStop = max(opts.LineStop, opts.LineStart)
+	lineCount := opts.LineStop - opts.LineStart + 1
+	if lineCount <= 0 || lineCount > 140 /* GitHub at most show 140 lines */ {
+		lineCount = 10
+		opts.LineStop = opts.LineStart + lineCount
+	}
+
+	dbRepo, err := repo.GetRepositoryByOwnerAndName(ctx, opts.OwnerName, opts.RepoName)
+	if err != nil {
+		return "", err
+	}
+
+	webCtx, ok := ctx.Value(gitea_context.WebContextKey).(*gitea_context.Context)
+	if !ok {
+		return "", fmt.Errorf("context is not a web context")
+	}
+	doer := webCtx.Doer
+
+	perms, err := access.GetUserRepoPermission(ctx, dbRepo, doer)
+	if err != nil {
+		return "", err
+	}
+	if !perms.CanRead(unit.TypeCode) {
+		return "", fmt.Errorf("no permission")
+	}
+
+	gitRepo, err := gitrepo.OpenRepository(ctx, dbRepo)
+	if err != nil {
+		return "", err
+	}
+	defer gitRepo.Close()
+
+	commit, err := gitRepo.GetCommit(opts.CommitID)
+	if err != nil {
+		return "", err
+	}
+
+	language, _ := files.TryGetContentLanguage(gitRepo, opts.CommitID, opts.FilePath)
+	blob, err := commit.GetBlobByPath(opts.FilePath)
+	if err != nil {
+		return "", err
+	}
+
+	if blob.Size() > setting.UI.MaxDisplayFileSize {
+		return "", fmt.Errorf("file is too large")
+	}
+
+	dataRc, err := blob.DataAsync()
+	if err != nil {
+		return "", err
+	}
+	defer dataRc.Close()
+
+	reader := bufio.NewReader(dataRc)
+	for i := 1; i < opts.LineStart; i++ {
+		if _, err = reader.ReadBytes('\n'); err != nil {
+			return "", err
+		}
+	}
+
+	lineNums := make([]int, 0, lineCount)
+	lineCodes := make([]string, 0, lineCount)
+	for i := opts.LineStart; i <= opts.LineStop; i++ {
+		if line, err := reader.ReadString('\n'); err != nil && line == "" {
+			break
+		} else {
+			lineNums = append(lineNums, i)
+			lineCodes = append(lineCodes, line)
+		}
+	}
+	realLineStop := max(opts.LineStart, opts.LineStart+len(lineNums)-1)
+	highlightLines := code.HighlightSearchResultCode(opts.FilePath, language, lineNums, strings.Join(lineCodes, ""))
+
+	escapeStatus := &charset.EscapeStatus{}
+	lineEscapeStatus := make([]*charset.EscapeStatus, len(highlightLines))
+	for i, hl := range highlightLines {
+		lineEscapeStatus[i], hl.FormattedContent = charset.EscapeControlHTML(hl.FormattedContent, webCtx.Base.Locale, charset.RuneNBSP)
+		escapeStatus = escapeStatus.Or(lineEscapeStatus[i])
+	}
+
+	return webCtx.RenderToHTML("base/markup_codepreview", map[string]any{
+		"FullURL":          opts.FullURL,
+		"FilePath":         opts.FilePath,
+		"LineStart":        opts.LineStart,
+		"LineStop":         realLineStop,
+		"RepoLink":         dbRepo.Link(),
+		"CommitID":         opts.CommitID,
+		"HighlightLines":   highlightLines,
+		"EscapeStatus":     escapeStatus,
+		"LineEscapeStatus": lineEscapeStatus,
+	})
+}
diff --git a/services/markup/processorhelper_codepreview_test.go b/services/markup/processorhelper_codepreview_test.go
new file mode 100644
index 0000000000..01db792925
--- /dev/null
+++ b/services/markup/processorhelper_codepreview_test.go
@@ -0,0 +1,83 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/unittest"
+	"code.gitea.io/gitea/modules/markup"
+	"code.gitea.io/gitea/modules/templates"
+	"code.gitea.io/gitea/services/contexttest"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestProcessorHelperCodePreview(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()})
+	htm, err := renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{
+		FullURL:   "http://full",
+		OwnerName: "user2",
+		RepoName:  "repo1",
+		CommitID:  "65f1bf27bc3bf70f64657658635e66094edbcb4d",
+		FilePath:  "/README.md",
+		LineStart: 1,
+		LineStop:  2,
+	})
+	assert.NoError(t, err)
+	assert.Equal(t, `<div class="code-preview-container file-content">
+	<div class="code-preview-header">
+		<a href="http://full" class="muted" rel="nofollow">/README.md</a>
+		repo.code_preview_line_from_to:1,2,<a href="/user2/repo1/src/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d" rel="nofollow">65f1bf27bc</a>
+	</div>
+	<table class="file-view">
+		<tbody><tr>
+				<td class="lines-num"><span data-line-number="1"></span></td>
+				<td class="lines-code chroma"><code class="code-inner"><span class="gh"># repo1</code></td>
+			</tr><tr>
+				<td class="lines-num"><span data-line-number="2"></span></td>
+				<td class="lines-code chroma"><code class="code-inner"></span><span class="gh"></span></code></td>
+			</tr></tbody>
+	</table>
+</div>
+`, string(htm))
+
+	ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()})
+	htm, err = renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{
+		FullURL:   "http://full",
+		OwnerName: "user2",
+		RepoName:  "repo1",
+		CommitID:  "65f1bf27bc3bf70f64657658635e66094edbcb4d",
+		FilePath:  "/README.md",
+		LineStart: 1,
+	})
+	assert.NoError(t, err)
+	assert.Equal(t, `<div class="code-preview-container file-content">
+	<div class="code-preview-header">
+		<a href="http://full" class="muted" rel="nofollow">/README.md</a>
+		repo.code_preview_line_in:1,<a href="/user2/repo1/src/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d" rel="nofollow">65f1bf27bc</a>
+	</div>
+	<table class="file-view">
+		<tbody><tr>
+				<td class="lines-num"><span data-line-number="1"></span></td>
+				<td class="lines-code chroma"><code class="code-inner"><span class="gh"># repo1</code></td>
+			</tr></tbody>
+	</table>
+</div>
+`, string(htm))
+
+	ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()})
+	_, err = renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{
+		FullURL:   "http://full",
+		OwnerName: "user15",
+		RepoName:  "big_test_private_1",
+		CommitID:  "65f1bf27bc3bf70f64657658635e66094edbcb4d",
+		FilePath:  "/README.md",
+		LineStart: 1,
+		LineStop:  10,
+	})
+	assert.ErrorContains(t, err, "no permission")
+}
diff --git a/templates/base/markup_codepreview.tmpl b/templates/base/markup_codepreview.tmpl
new file mode 100644
index 0000000000..c65ab28406
--- /dev/null
+++ b/templates/base/markup_codepreview.tmpl
@@ -0,0 +1,25 @@
+<div class="code-preview-container file-content">
+	<div class="code-preview-header">
+		<a href="{{.FullURL}}" class="muted" rel="nofollow">{{.FilePath}}</a>
+		{{$link := HTMLFormat `<a href="%s/src/commit/%s" rel="nofollow">%s</a>` .RepoLink .CommitID (.CommitID | ShortSha) -}}
+		{{- if eq .LineStart .LineStop -}}
+			{{ctx.Locale.Tr "repo.code_preview_line_in" .LineStart $link}}
+		{{- else -}}
+			{{ctx.Locale.Tr "repo.code_preview_line_from_to" .LineStart .LineStop $link}}
+		{{- end}}
+	</div>
+	<table class="file-view">
+		<tbody>
+			{{- range $idx, $line := .HighlightLines -}}
+			<tr>
+				<td class="lines-num"><span data-line-number="{{$line.Num}}"></span></td>
+				{{- if $.EscapeStatus.Escaped -}}
+					{{- $lineEscapeStatus := index $.LineEscapeStatus $idx -}}
+					<td class="lines-escape">{{if $lineEscapeStatus.Escaped}}<a href="#" class="toggle-escape-button btn interact-bg" title="{{if $lineEscapeStatus.HasInvisible}}{{ctx.Locale.Tr "repo.invisible_runes_line"}} {{end}}{{if $lineEscapeStatus.HasAmbiguous}}{{ctx.Locale.Tr "repo.ambiguous_runes_line"}}{{end}}"></a>{{end}}</td>
+				{{- end}}
+				<td class="lines-code chroma"><code class="code-inner">{{$line.FormattedContent}}</code></td>
+			</tr>
+			{{- end -}}
+		</tbody>
+	</table>
+</div>
diff --git a/web_src/css/base.css b/web_src/css/base.css
index 96c90ee692..05ddba3223 100644
--- a/web_src/css/base.css
+++ b/web_src/css/base.css
@@ -1186,10 +1186,13 @@ overflow-menu .ui.label {
   content: attr(data-line-number);
   line-height: 20px !important;
   padding: 0 10px;
-  cursor: pointer;
   display: block;
 }
 
+.code-view .lines-num span::after {
+  cursor: pointer;
+}
+
 .lines-type-marker {
   vertical-align: top;
 }
diff --git a/web_src/css/index.css b/web_src/css/index.css
index 40b1d3c881..7be8065dc7 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -41,6 +41,7 @@
 
 @import "./markup/content.css";
 @import "./markup/codecopy.css";
+@import "./markup/codepreview.css";
 @import "./markup/asciicast.css";
 
 @import "./chroma/base.css";
diff --git a/web_src/css/markup/codepreview.css b/web_src/css/markup/codepreview.css
new file mode 100644
index 0000000000..9219544993
--- /dev/null
+++ b/web_src/css/markup/codepreview.css
@@ -0,0 +1,36 @@
+.markup .code-preview-container {
+  border: 1px solid var(--color-secondary);
+  border-radius: var(--border-radius);
+  margin: 0.25em 0;
+}
+
+.markup .code-preview-container .code-preview-header {
+  border-bottom: 1px solid var(--color-secondary);
+  padding: 0.5em;
+  font-size: 12px;
+}
+
+.markup .code-preview-container table {
+  width: 100%;
+  max-height: 100px;
+  overflow-y: auto;
+  margin: 0; /* override ".markup table {margin}" */
+}
+
+/* workaround to hide empty p before container - more details are in "html_codepreview.go" */
+.markup p:empty:has(+ .code-preview-container) {
+  display: none;
+}
+
+/* override the polluted styles from the content.css: ".markup table ..." */
+.markup .code-preview-container table tr {
+  border: 0 !important;
+}
+.markup .code-preview-container table th,
+.markup .code-preview-container table td {
+  border: 0 !important;
+  padding: 0 0 0 5px !important;
+}
+.markup .code-preview-container table tr:nth-child(2n) {
+  background: none !important;
+}
diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css
index 5eeef078a5..376d3030c7 100644
--- a/web_src/css/markup/content.css
+++ b/web_src/css/markup/content.css
@@ -382,7 +382,7 @@
   text-align: center;
 }
 
-.markup span.align-center span img
+.markup span.align-center span img,
 .markup span.align-center span video {
   margin: 0 auto;
   text-align: center;
@@ -432,7 +432,7 @@
   text-align: right;
 }
 
-.markup code,
+.markup code:not(.code-inner),
 .markup tt {
   padding: 0.2em 0.4em;
   margin: 0;