Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show 🚫 on the last line when a file does not end with \n #27391

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions modules/highlight/highlight.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"bytes"
"fmt"
gohtml "html"
"html/template"
"io"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
19 changes: 15 additions & 4 deletions modules/highlight/highlight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package highlight

import (
"html/template"
"strings"
"testing"

Expand All @@ -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
Expand Down Expand Up @@ -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, "<span"), strings.Count(actual, "</span>"))
assert.EqualValues(t, expected, actual)
assert.Equal(t, tt.lexerName, lexerName)
assert.Equal(t, tt.lexerName, out.LexerName)
})
}
}
Expand Down Expand Up @@ -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)
})
}
Expand Down
1 change: 1 addition & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ step2 = Step 2:
error = Error
error404 = The page you are trying to reach either <strong>does not exist</strong> or <strong>you are not authorized</strong> to view it.
go_back = Go Back
file_missing_final_newline = No newline at end of file

never = Never
unknown = Unknown
Expand Down
48 changes: 25 additions & 23 deletions routers/web/repo/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
gocontext "context"
"encoding/base64"
"fmt"
"html/template"
"image"
"io"
"net/http"
Expand Down Expand Up @@ -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()
Expand All @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions templates/repo/view_file.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,16 @@
{{else}}
<table>
<tbody>
{{range $idx, $code := .FileContent}}
{{range $idx, $codeHTML := .FileContentLines.HTMLLines}}
{{$line := Eval $idx "+" 1}}
<tr>
<td id="L{{$line}}" class="lines-num"><span id="L{{$line}}" data-line-number="{{$line}}"></span></td>
{{if $.EscapeStatus.Escaped}}
<td class="lines-escape">{{if (index $.LineEscapeStatus $idx).Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{if (index $.LineEscapeStatus $idx).HasInvisible}}{{ctx.Locale.Tr "repo.invisible_runes_line"}} {{end}}{{if (index $.LineEscapeStatus $idx).HasAmbiguous}}{{ctx.Locale.Tr "repo.ambiguous_runes_line"}}{{end}}"></button>{{end}}</td>
{{end}}
<td rel="L{{$line}}" class="lines-code chroma"><code class="code-inner">{{$code | Safe}}</code></td>
<td rel="L{{$line}}" class="lines-code chroma"><code class="code-inner">{{$codeHTML}}</code>
{{- if $.FileContentLines.ShouldShowIncompleteMark $idx -}}<span class="text red gt-ml-2" data-tooltip-content="{{ctx.Locale.Tr "file_missing_final_newline"}}">{{svg "octicon-no-entry" 14}}</span>{{- end -}}
</td>
</tr>
{{end}}
</tbody>
Expand Down
2 changes: 1 addition & 1 deletion web_src/js/features/copycontent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
}

Expand Down