diff --git a/modules/charset/escape_stream.go b/modules/charset/escape_stream.go index 22e7f14f39..5aa15a6fa9 100644 --- a/modules/charset/escape_stream.go +++ b/modules/charset/escape_stream.go @@ -18,6 +18,19 @@ import ( // VScode defaultWordRegexp var defaultWordRegexp = regexp.MustCompile(`(-?\d*\.\d\w*)|([^\` + "`" + `\~\!\@\#\$\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s\x00-\x1f]+)`) +// ControlCharPicture returns the Unicode Control Picture for ASCII control +// characters (0x00-0x1F → U+2400-U+241F, 0x7F → U+2421). For other runes it +// returns 0, false. +func ControlCharPicture(r rune) (rune, bool) { + if r >= 0 && r <= 0x1f { + return 0x2400 + r, true + } + if r == 0x7f { + return 0x2421, true + } + return 0, false +} + func NewEscapeStreamer(locale translation.Locale, next HTMLStreamer, allowed ...rune) HTMLStreamer { allowedM := make(map[rune]bool, len(allowed)) for _, v := range allowed { @@ -199,12 +212,19 @@ func (e *escapeStreamer) invisibleRune(r rune) error { e.escaped.Escaped = true e.escaped.HasInvisible = true + var escaped string + if pic, ok := ControlCharPicture(r); ok { + escaped = string(pic) + } else { + escaped = fmt.Sprintf("[U+%04X]", r) + } + if err := e.PassthroughHTMLStreamer.StartTag("span", html.Attribute{ Key: "class", Val: "escaped-code-point", }, html.Attribute{ Key: "data-escaped", - Val: fmt.Sprintf("[U+%04X]", r), + Val: escaped, }); err != nil { return err } diff --git a/modules/charset/escape_test.go b/modules/charset/escape_test.go index 9d796a0c18..d79a4df6c6 100644 --- a/modules/charset/escape_test.go +++ b/modules/charset/escape_test.go @@ -151,7 +151,7 @@ func TestEscapeControlReader(t *testing.T) { for _, test := range escapeControlTests { test.name += " (+Control)" test.text = addPrefix("\u001E", test.text) - test.result = addPrefix(``+"\u001e"+``, test.result) + test.result = addPrefix(``+"\u001e"+``, test.result) test.status.Escaped = true test.status.HasInvisible = true tests = append(tests, test) diff --git a/modules/highlight/highlight.go b/modules/highlight/highlight.go index addc372f85..2d98805a0a 100644 --- a/modules/highlight/highlight.go +++ b/modules/highlight/highlight.go @@ -10,6 +10,7 @@ import ( "slices" "sync" + "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -43,20 +44,12 @@ func globalVars() *globalVarsType { globalVarsPtr.githubStyles = styles.Get("github") globalVarsPtr.highlightMapping = setting.GetHighlightMapping() globalVarsPtr.escCtrlCharsMap = make([]template.HTML, 256) - // ASCII Table 0x00 - 0x1F - controlCharNames := []string{ - "NUL", "SOH", "STX", "ETX", "EOT", "ENQ", "ACK", "BEL", - "BS", "HT", "LF", "VT", "FF", "CR", "SO", "SI", - "DLE", "DC1", "DC2", "DC3", "DC4", "NAK", "SYN", "ETB", - "CAN", "EM", "SUB", "ESC", "FS", "GS", "RS", "US", + for i := range 0x20 { + pic, _ := charset.ControlCharPicture(rune(i)) + globalVarsPtr.escCtrlCharsMap[i] = controlCharHTML(pic, byte(i)) } - // Uncomment this line if you'd debug the layout without creating a special file, then Space (0x20) will also be escaped. - // Don't worry, even if you forget to comment it out and push it to git repo, the CI tests will catch it and fail. - // controlCharNames = append(controlCharNames, "SP") - for i, s := range controlCharNames { - globalVarsPtr.escCtrlCharsMap[i] = template.HTML(`` + string(byte(i)) + ``) - } - globalVarsPtr.escCtrlCharsMap[0x7f] = template.HTML(`` + string(byte(0x7f)) + ``) + pic, _ := charset.ControlCharPicture(0x7f) + globalVarsPtr.escCtrlCharsMap[0x7f] = controlCharHTML(pic, 0x7f) globalVarsPtr.escCtrlCharsMap['\t'] = "" globalVarsPtr.escCtrlCharsMap['\n'] = "" globalVarsPtr.escCtrlCharsMap['\r'] = "" @@ -72,6 +65,10 @@ func globalVars() *globalVarsType { return globalVarsPtr } +func controlCharHTML(pic rune, char byte) template.HTML { + return template.HTML(`` + string(char) + ``) +} + func escapeByMap(code []byte, escapeMap []template.HTML) template.HTML { firstEscapePos := -1 for i, c := range code { @@ -175,11 +172,7 @@ func RenderCodeByLexer(lexer chroma.Lexer, code string) template.HTML { return escapeFullString(code) } - // At the moment, we do not escape control chars here (unlike RenderFullFile which escapes control chars). - // The reason is: it is a very rare case that a text file contains control chars. - // This function is usually used by highlight diff and blame, not quite sure whether there will be side effects. - // If there would be new user feedback about this, we can re-consider about various edge cases. - return template.HTML(htmlBuf.String()) + return escapeControlChars(htmlBuf.Bytes()) } // RenderFullFile returns a slice of chroma syntax highlighted HTML lines of code and the matched lexer name @@ -191,10 +184,9 @@ func RenderFullFile(fileName, language string, code []byte) ([]template.HTML, st lexerName := formatLexerName(lexer.Config().Name) rendered := RenderCodeByLexer(lexer, util.UnsafeBytesToString(code)) unsafeLines := UnsafeSplitHighlightedLines(rendered) - lines := make([]template.HTML, 0, len(unsafeLines)) - for _, lineBytes := range unsafeLines { - line := escapeControlChars(lineBytes) - lines = append(lines, line) + lines := make([]template.HTML, len(unsafeLines)) + for idx, lineBytes := range unsafeLines { + lines[idx] = template.HTML(lineBytes) } return lines, lexerName } diff --git a/modules/highlight/highlight_test.go b/modules/highlight/highlight_test.go index cad22ba9bb..2bd298e780 100644 --- a/modules/highlight/highlight_test.go +++ b/modules/highlight/highlight_test.go @@ -206,12 +206,12 @@ func TestUnsafeSplitHighlightedLines(t *testing.T) { } func TestEscape(t *testing.T) { - assert.Equal(t, template.HTML("\t\r\n\x00\x1f&'\"<>"), escapeControlChars([]byte("\t\r\n\x00\x1f&'\"<>"))) - assert.Equal(t, template.HTML("\x00\x1f&'"<>\t\r\n"), escapeFullString("\x00\x1f&'\"<>\t\r\n")) + assert.Equal(t, template.HTML("\t\r\n\x00\x1f&'\"<>"), escapeControlChars([]byte("\t\r\n\x00\x1f&'\"<>"))) + assert.Equal(t, template.HTML("\x00\x1f&'"<>\t\r\n"), escapeFullString("\x00\x1f&'\"<>\t\r\n")) out, _ := RenderFullFile("a.py", "", []byte("# \x7f<>")) - assert.Equal(t, template.HTML(`# `+string(byte(0x7f))+`<>`), out[0]) + assert.Equal(t, template.HTML(`# `+string(byte(0x7f))+`<>`), out[0]) out = renderPlainText([]byte("# \x7f<>")) - assert.Equal(t, template.HTML(`# `+string(byte(0x7f))+`<>`), out[0]) + assert.Equal(t, template.HTML(`# `+string(byte(0x7f))+`<>`), out[0]) } diff --git a/web_src/css/base.css b/web_src/css/base.css index bb16b9fe21..a8d9dea2a2 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -17,6 +17,7 @@ /* images */ --checkbox-mask-checked: url('data:image/svg+xml;utf8,'); --checkbox-mask-indeterminate: url('data:image/svg+xml;utf8,'); + --octicon-alert-fill: url('data:image/svg+xml;utf8,'); --octicon-chevron-right: url('data:image/svg+xml;utf8,'); --octicon-x: url('data:image/svg+xml;utf8,'); --select-arrows: url('data:image/svg+xml;utf8,'); @@ -686,6 +687,7 @@ overflow-menu .ui.label { } .lines-num, +.lines-escape, .lines-code { font-size: 12px; font-family: var(--fonts-monospace); diff --git a/web_src/css/modules/charescape.css b/web_src/css/modules/charescape.css index 0c9cbb55b5..6f3dc99028 100644 --- a/web_src/css/modules/charescape.css +++ b/web_src/css/modules/charescape.css @@ -1,24 +1,33 @@ /* Show the escaped and hide the real char: - {real-char} + {real-char} Only show the real-char: {real-char} */ -.broken-code-point:not([data-escaped]), -.broken-code-point[data-escaped]::before { +.broken-code-point:not([data-escaped]) { border-radius: 4px; padding: 0 2px; color: var(--color-body); background: var(--color-text-light-1); } +.broken-code-point[data-escaped] { + position: relative; +} + .broken-code-point[data-escaped]::before { visibility: visible; content: attr(data-escaped); + border-radius: 2px; + padding: 0 1px; + color: var(--color-body); + background: var(--color-text-light-1); } + .broken-code-point[data-escaped] .char { - /* make it copyable by selecting the text (AI suggestion, no other solution) */ + /* keep the original character selectable/copyable while showing the escaped label via ::before */ position: absolute; + left: 0; opacity: 0; pointer-events: none; } @@ -26,11 +35,11 @@ Only show the real-char: /* Show the escaped and hide the real-char: - {real-char} + {real-char} Hide the escaped and show the real-char: - {real-char} + {real-char} */ .unicode-escaped .escaped-code-point[data-escaped]::before { diff --git a/web_src/css/review.css b/web_src/css/review.css index 9e320346d8..8fb5e1e29a 100644 --- a/web_src/css/review.css +++ b/web_src/css/review.css @@ -15,11 +15,23 @@ transform: scale(1.1); } +.lines-escape .toggle-escape-button { + padding: 2px; + transform: translateY(-1px); +} + .lines-escape .toggle-escape-button::before { visibility: visible; - content: "⚠️"; - font-family: var(--fonts-emoji); - color: var(--color-red); + content: ""; + display: inline-block; + width: 14px; + height: 14px; + vertical-align: middle; + background-color: var(--color-yellow); + mask-image: var(--octicon-alert-fill); + -webkit-mask-image: var(--octicon-alert-fill); + mask-size: contain; + -webkit-mask-size: contain; } .repository .diff-file-box .code-diff td.lines-escape {