diff --git a/modules/highlight/highlight.go b/modules/highlight/highlight.go index a67217e864675..f278c23c872a8 100644 --- a/modules/highlight/highlight.go +++ b/modules/highlight/highlight.go @@ -9,6 +9,7 @@ import ( "bytes" "fmt" gohtml "html" + "html/template" "io" "path/filepath" "strings" @@ -135,12 +136,26 @@ func CodeFromLexer(lexer chroma.Lexer, code string) string { return strings.TrimSuffix(htmlbuf.String(), "\n") } +type ContentLines struct { + HTMLLines []template.HTML + HasLastEOL bool + LexerName string +} + +func (cl *ContentLines) ShouldShowIncompleteMark(idx int) bool { + return !cl.HasLastEOL && idx == len(cl.HTMLLines)-1 +} + +func hasLastEOL(code []byte) bool { + return len(code) != 0 && code[len(code)-1] == '\n' +} + // File returns a slice of chroma syntax highlighted HTML lines of code and the matched lexer name -func File(fileName, language string, code []byte) ([]string, string, error) { +func File(fileName, language string, code []byte) (*ContentLines, error) { NewContext() if len(code) > sizeLimit { - return PlainText(code), "", nil + return PlainText(code), nil } formatter := html.New(html.WithClasses(true), @@ -177,30 +192,30 @@ func File(fileName, language string, code []byte) ([]string, string, error) { iterator, err := lexer.Tokenise(nil, string(code)) if err != nil { - return nil, "", fmt.Errorf("can't tokenize code: %w", err) + return nil, fmt.Errorf("can't tokenize code: %w", err) } tokensLines := chroma.SplitTokensIntoLines(iterator.Tokens()) htmlBuf := &bytes.Buffer{} - lines := make([]string, 0, len(tokensLines)) + lines := make([]template.HTML, 0, len(tokensLines)) for _, tokens := range tokensLines { iterator = chroma.Literator(tokens...) err = formatter.Format(htmlBuf, githubStyles, iterator) if err != nil { - return nil, "", fmt.Errorf("can't format code: %w", err) + return nil, fmt.Errorf("can't format code: %w", err) } - lines = append(lines, htmlBuf.String()) + lines = append(lines, template.HTML(htmlBuf.String())) htmlBuf.Reset() } - return lines, lexerName, nil + return &ContentLines{HTMLLines: lines, HasLastEOL: hasLastEOL(code), LexerName: lexerName}, nil } // PlainText returns non-highlighted HTML for code -func PlainText(code []byte) []string { +func PlainText(code []byte) *ContentLines { r := bufio.NewReader(bytes.NewReader(code)) - m := make([]string, 0, bytes.Count(code, []byte{'\n'})+1) + m := make([]template.HTML, 0, bytes.Count(code, []byte{'\n'})+1) for { content, err := r.ReadString('\n') if err != nil && err != io.EOF { @@ -210,10 +225,9 @@ func PlainText(code []byte) []string { if content == "" && err == io.EOF { break } - s := gohtml.EscapeString(content) - m = append(m, s) + m = append(m, template.HTML(gohtml.EscapeString(content))) } - return m + return &ContentLines{HTMLLines: m, HasLastEOL: hasLastEOL(code)} } func formatLexerName(name string) string { diff --git a/modules/highlight/highlight_test.go b/modules/highlight/highlight_test.go index 7a9887728f18d..73c2afb105aaa 100644 --- a/modules/highlight/highlight_test.go +++ b/modules/highlight/highlight_test.go @@ -4,6 +4,7 @@ package highlight import ( + "html/template" "strings" "testing" @@ -14,6 +15,16 @@ func lines(s string) []string { return strings.Split(strings.ReplaceAll(strings.TrimSpace(s), `\n`, "\n"), "\n") } +func join(lines []template.HTML, sep string) (s string) { + for i, line := range lines { + s += string(line) + if i != len(lines)-1 { + s += sep + } + } + return s +} + func TestFile(t *testing.T) { tests := []struct { name string @@ -97,13 +108,13 @@ c=2 for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - out, lexerName, err := File(tt.name, "", []byte(tt.code)) + out, err := File(tt.name, "", []byte(tt.code)) assert.NoError(t, err) expected := strings.Join(tt.want, "\n") - actual := strings.Join(out, "\n") + actual := join(out.HTMLLines, "\n") assert.Equal(t, strings.Count(actual, "")) assert.EqualValues(t, expected, actual) - assert.Equal(t, tt.lexerName, lexerName) + assert.Equal(t, tt.lexerName, out.LexerName) }) } } @@ -166,7 +177,7 @@ c=2`), t.Run(tt.name, func(t *testing.T) { out := PlainText([]byte(tt.code)) expected := strings.Join(tt.want, "\n") - actual := strings.Join(out, "\n") + actual := join(out.HTMLLines, "\n") assert.EqualValues(t, expected, actual) }) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 7999757b833da..cdbf48b5aa8e0 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -114,6 +114,7 @@ step2 = Step 2: error = Error error404 = The page you are trying to reach either does not exist or you are not authorized to view it. go_back = Go Back +file_missing_final_newline = No newline at end of file never = Never unknown = Unknown diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 657179062736c..a83d7b69ccd91 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -9,6 +9,7 @@ import ( gocontext "context" "encoding/base64" "fmt" + "html/template" "image" "io" "net/http" @@ -488,22 +489,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st } else { buf, _ := io.ReadAll(rd) - // The Open Group Base Specification: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html - // empty: 0 lines; "a": 1 line, 1 incomplete-line; "a\n": 1 line; "a\nb": 1 line, 1 incomplete-line; - // Gitea uses the definition (like most modern editors): - // empty: 0 lines; "a": 1 line; "a\n": 2 lines; "a\nb": 2 lines; - // When rendering, the last empty line is not rendered in UI, while the line-number is still counted, to tell users that the file contains a trailing EOL. - // To make the UI more consistent, it could use an icon mark to indicate that there is no trailing EOL, and show line-number as the rendered lines. - // This NumLines is only used for the display on the UI: "xxx lines" - if len(buf) == 0 { - ctx.Data["NumLines"] = 0 - } else { - ctx.Data["NumLines"] = bytes.Count(buf, []byte{'\n'}) + 1 - } - ctx.Data["NumLinesSet"] = true - - language := "" - + var language string indexFilename, worktree, deleteTemporaryFile, err := ctx.Repo.GitRepo.ReadTreeToTemporaryIndex(ctx.Repo.CommitID) if err == nil { defer deleteTemporaryFile() @@ -527,21 +513,37 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st language = "" } } - fileContent, lexerName, err := highlight.File(blob.Name(), language, buf) - ctx.Data["LexerName"] = lexerName + fileContentLines, 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) + fileContentLines = highlight.PlainText(buf) + } else { + ctx.Data["LexerName"] = fileContentLines.LexerName // the LexerName field is also used by "blame" page } status := &charset.EscapeStatus{} - statuses := make([]*charset.EscapeStatus, len(fileContent)) - for i, line := range fileContent { - statuses[i], fileContent[i] = charset.EscapeControlHTML(line, ctx.Locale) + statuses := make([]*charset.EscapeStatus, len(fileContentLines.HTMLLines)) + for i, line := range fileContentLines.HTMLLines { + st, htm := charset.EscapeControlHTML(string(line), ctx.Locale) + statuses[i], fileContentLines.HTMLLines[i] = st, template.HTML(htm) status = status.Or(statuses[i]) } ctx.Data["EscapeStatus"] = status - ctx.Data["FileContent"] = fileContent + ctx.Data["FileContentLines"] = fileContentLines ctx.Data["LineEscapeStatus"] = statuses + + // The Open Group Base Specification: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html + // empty: 0 lines; "a": 1 incomplete-line; "a\n": 1 line; "a\nb": 1 line, 1 incomplete-line; + // Gitea uses the definition (like most modern editors): + // empty: 0 lines; "a": 1 line; "a\n": 2 lines (only 1 line is rendered); "a\nb": 2 lines; + // When rendering, the last empty line is not rendered on UI, so "a\n" will be only rendered as one line on the UI. + // If the content doesn't end with an EOL, there will be an icon mark at the end of last line to distinguish from the case above. + // This NumLines is only used for the display purpose on the UI: "xxx lines" + if len(buf) == 0 { + ctx.Data["NumLines"] = 0 + } else { + ctx.Data["NumLines"] = len(fileContentLines.HTMLLines) + } + ctx.Data["NumLinesSet"] = true } if !fInfo.isLFSFile { if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 0064da96320d7..b4b9df3a92ac1 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -102,14 +102,16 @@ {{else}} - {{range $idx, $code := .FileContent}} + {{range $idx, $codeHTML := .FileContentLines.HTMLLines}} {{$line := Eval $idx "+" 1}} {{if $.EscapeStatus.Escaped}} {{end}} - + {{end}} diff --git a/web_src/js/features/copycontent.js b/web_src/js/features/copycontent.js index 3d3b2a697ecbf..5cdf0dea99346 100644 --- a/web_src/js/features/copycontent.js +++ b/web_src/js/features/copycontent.js @@ -36,7 +36,7 @@ export function initCopyContent() { btn.classList.remove('is-loading', 'small-loading-icon'); } } else { // text, read from DOM - const lineEls = document.querySelectorAll('.file-view .lines-code'); + const lineEls = document.querySelectorAll('.file-view .lines-code .code-inner'); content = Array.from(lineEls, (el) => el.textContent).join(''); }
{{if (index $.LineEscapeStatus $idx).Escaped}}{{end}}{{$code | Safe}}{{$codeHTML}} + {{- if $.FileContentLines.ShouldShowIncompleteMark $idx -}}{{svg "octicon-no-entry" 14}}{{- end -}} +