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 {