detached 008636c

feat(web): highlight fenced code blocks in markdown

commit
008636c9ecb3e3771b62dccec1d60fe77cf46534
author
brice · · 2026-10-20 14:38 UTC
parent
c899179
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +228 -5
M internal/adapters/web/highlight.go +4 -4
@@ -264,17 +264,17 @@ func invisibleName(r rune) string {
264264 // writeMarked writes s escaped for HTML, each invisible character replaced
265265 // by <span class="ctl" title="U+202E right-to-left override">&lt;U+202E&gt;
266266 // </span>. The character itself is never output.
267-func writeMarked(b *strings.Builder, s string) {
267+func writeMarked(b interface{ WriteString(string) (int, error) }, s string) {
268268 for {
269269 i := strings.IndexFunc(s, invisible)
270270 if i < 0 {
271- b.WriteString(html.EscapeString(s))
271+ _, _ = b.WriteString(html.EscapeString(s))
272272 return
273273 }
274- b.WriteString(html.EscapeString(s[:i]))
274+ _, _ = b.WriteString(html.EscapeString(s[:i]))
275275 r, size := utf8.DecodeRuneInString(s[i:])
276276 cp := codePoint(r)
277- b.WriteString(`<span class="ctl" title="` + cp + ` ` + invisibleName(r) + `">&lt;` + cp + `&gt;</span>`)
277+ _, _ = b.WriteString(`<span class="ctl" title="` + cp + ` ` + invisibleName(r) + `">&lt;` + cp + `&gt;</span>`)
278278 s = s[i+size:]
279279 }
280280 }
@@ -264,17 +264,17 @@ func invisibleName(r rune) string {
264 // writeMarked writes s escaped for HTML, each invisible character replaced264 // writeMarked writes s escaped for HTML, each invisible character replaced
265 // by <span class="ctl" title="U+202E right-to-left override">&lt;U+202E&gt;265 // by <span class="ctl" title="U+202E right-to-left override">&lt;U+202E&gt;
266 // </span>. The character itself is never output.266 // </span>. The character itself is never output.
267-func writeMarked(b *strings.Builder, s string) {267+func writeMarked(b interface{ WriteString(string) (int, error) }, s string) {
268 for {268 for {
269 i := strings.IndexFunc(s, invisible)269 i := strings.IndexFunc(s, invisible)
270 if i < 0 {270 if i < 0 {
271- b.WriteString(html.EscapeString(s))271+ _, _ = b.WriteString(html.EscapeString(s))
272 return272 return
273 }273 }
274- b.WriteString(html.EscapeString(s[:i]))274+ _, _ = b.WriteString(html.EscapeString(s[:i]))
275 r, size := utf8.DecodeRuneInString(s[i:])275 r, size := utf8.DecodeRuneInString(s[i:])
276 cp := codePoint(r)276 cp := codePoint(r)
277- b.WriteString(`<span class="ctl" title="` + cp + ` ` + invisibleName(r) + `">&lt;` + cp + `&gt;</span>`)277+ _, _ = b.WriteString(`<span class="ctl" title="` + cp + ` ` + invisibleName(r) + `">&lt;` + cp + `&gt;</span>`)
278 s = s[i+size:]278 s = s[i+size:]
279 }279 }
280 }280 }
M internal/adapters/web/markdown.go +3 -1
@@ -10,6 +10,7 @@ import (
1010 "regexp"
1111 "strconv"
1212 "strings"
13+ "time"
1314
1415 "github.com/yuin/goldmark"
1516 "github.com/yuin/goldmark/ast"
@@ -45,7 +46,7 @@ func newMarkdown() *markdown {
4546 goldmark.WithExtensions(extension.GFM),
4647 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
4748 goldmark.WithRendererOptions(
48- renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100)),
49+ renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100), util.Prioritized(fencedRenderer{}, 100)),
4950 // No html.WithUnsafe(): raw HTML blocks and inline tags are dropped.
5051 // Table alignment as an align attribute: the CSP forbids style="".
5152 extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute),
@@ -111,6 +112,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64)
111112 noteReferences(t, source, notes)
112113 }
113114
115+ doc.SetAttributeString(markdownBudgetKey, &markdownLexBudget{deadline: time.Now().Add(markdownLexTime)})
114116 var buf bytes.Buffer
115117 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
116118 return "", nil, err
@@ -10,6 +10,7 @@ import (
10 "regexp"10 "regexp"
11 "strconv"11 "strconv"
12 "strings"12 "strings"
13+ "time"
13 14
14 "github.com/yuin/goldmark"15 "github.com/yuin/goldmark"
15 "github.com/yuin/goldmark/ast"16 "github.com/yuin/goldmark/ast"
@@ -45,7 +46,7 @@ func newMarkdown() *markdown {
45 goldmark.WithExtensions(extension.GFM),46 goldmark.WithExtensions(extension.GFM),
46 goldmark.WithParserOptions(parser.WithAutoHeadingID()),47 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
47 goldmark.WithRendererOptions(48 goldmark.WithRendererOptions(
48- renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100)),49+ renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100), util.Prioritized(fencedRenderer{}, 100)),
49 // No html.WithUnsafe(): raw HTML blocks and inline tags are dropped.50 // No html.WithUnsafe(): raw HTML blocks and inline tags are dropped.
50 // Table alignment as an align attribute: the CSP forbids style="".51 // Table alignment as an align attribute: the CSP forbids style="".
51 extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute),52 extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute),
@@ -111,6 +112,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64)
111 noteReferences(t, source, notes)112 noteReferences(t, source, notes)
112 }113 }
113 114
115+ doc.SetAttributeString(markdownBudgetKey, &markdownLexBudget{deadline: time.Now().Add(markdownLexTime)})
114 var buf bytes.Buffer116 var buf bytes.Buffer
115 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {117 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
116 return "", nil, err118 return "", nil, err
A internal/adapters/web/markdown_highlight.go +157 -0
@@ -0,0 +1,157 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "strings"
8+ "time"
9+
10+ "github.com/alecthomas/chroma/v2"
11+ "github.com/alecthomas/chroma/v2/lexers"
12+ "github.com/yuin/goldmark/ast"
13+ "github.com/yuin/goldmark/renderer"
14+ goldhtml "github.com/yuin/goldmark/renderer/html"
15+ "github.com/yuin/goldmark/util"
16+)
17+
18+const (
19+ markdownBlockBytes = 64 << 10
20+ markdownDocumentBytes = 256 << 10
21+ markdownLexTime = 200 * time.Millisecond
22+ markdownBudgetKey = "markdown-lex-budget"
23+)
24+
25+// Budget belongs to the parsed document, never to the shared renderer.
26+// Charge attempts, including failures, so repeated bad blocks cannot bypass it.
27+type markdownLexBudget struct {
28+ bytes, blocks int
29+ deadline time.Time
30+}
31+
32+type fencedRenderer struct{}
33+
34+// Capture goldmark's default renderer rather than duplicate its escaping rules.
35+type fencedFallback struct{ render renderer.NodeRendererFunc }
36+
37+func (f *fencedFallback) Register(k ast.NodeKind, fn renderer.NodeRendererFunc) {
38+ if k == ast.KindFencedCodeBlock {
39+ f.render = fn
40+ }
41+}
42+
43+func (fencedRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
44+ var fallback fencedFallback
45+ goldhtml.NewRenderer().RegisterFuncs(&fallback)
46+ r.Register(ast.KindFencedCodeBlock, func(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
47+ n := node.(*ast.FencedCodeBlock)
48+ if !entering {
49+ return ast.WalkContinue, nil
50+ }
51+ toks, lang, ok := markdownTokens(n, source)
52+ if !ok {
53+ // Default rendering is plain escaped text. Omit unsafe language classes.
54+ if !safeMarkdownLanguage(string(n.Language(source))) {
55+ plain := ast.NewFencedCodeBlock(nil)
56+ plain.SetLines(n.Lines())
57+ n = plain
58+ }
59+ _, err := fallback.render(w, source, n, true)
60+ if err != nil {
61+ return ast.WalkStop, err
62+ }
63+ return fallback.render(w, source, n, false)
64+ }
65+ _, _ = w.WriteString(`<pre class="chroma"><code class="language-` + lang + `">`)
66+ // EnsureNL lexers may append a newline: never emit bytes not in the block.
67+ remaining := 0
68+ for i := 0; i < n.Lines().Len(); i++ {
69+ line := n.Lines().At(i)
70+ remaining += len(line.Value(source))
71+ }
72+ for _, tk := range toks {
73+ v := tk.Value
74+ if len(v) > remaining {
75+ v = v[:remaining]
76+ }
77+ remaining -= len(v)
78+ if v == "" {
79+ continue
80+ }
81+ c := tokenClass(tk.Type)
82+ if c != "" {
83+ _, _ = w.WriteString(`<span class="` + c + `">`)
84+ }
85+ writeMarked(w, v)
86+ if c != "" {
87+ _, _ = w.WriteString(`</span>`)
88+ }
89+ }
90+ _, _ = w.WriteString("</code></pre>\n")
91+ return ast.WalkSkipChildren, nil
92+ })
93+}
94+
95+func safeMarkdownLanguage(s string) bool {
96+ if s == "" {
97+ return false
98+ }
99+ for _, c := range s {
100+ switch {
101+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', strings.ContainsRune("_+.#-", c):
102+ default:
103+ return false
104+ }
105+ }
106+ return true
107+}
108+
109+func markdownTokens(n *ast.FencedCodeBlock, source []byte) ([]chroma.Token, string, bool) {
110+ var lang string
111+ if n.Info != nil {
112+ if words := strings.Fields(string(n.Info.Segment.Value(source))); len(words) > 0 {
113+ lang = words[0]
114+ }
115+ }
116+ if !safeMarkdownLanguage(lang) {
117+ return nil, "", false
118+ }
119+ value, _ := n.OwnerDocument().AttributeString(markdownBudgetKey)
120+ budget, ok := value.(*markdownLexBudget)
121+ if !ok || budget.blocks >= 128 || time.Now().After(budget.deadline) {
122+ return nil, "", false
123+ }
124+ size := 0
125+ for i := 0; i < n.Lines().Len(); i++ {
126+ line := n.Lines().At(i)
127+ size += len(line.Value(source))
128+ if size > markdownBlockBytes {
129+ return nil, "", false
130+ }
131+ }
132+ if size > markdownDocumentBytes-budget.bytes {
133+ return nil, "", false
134+ }
135+ budget.blocks++
136+ budget.bytes += size
137+ lx := lexers.Get(lang)
138+ if lx == nil {
139+ return nil, "", false
140+ }
141+ select {
142+ case lexSlots <- struct{}{}:
143+ defer func() { <-lexSlots }()
144+ default:
145+ return nil, "", false
146+ }
147+ var code strings.Builder
148+ code.Grow(size)
149+ for i := 0; i < n.Lines().Len(); i++ {
150+ line := n.Lines().At(i)
151+ code.Write(line.Value(source))
152+ }
153+ ctx, cancel := context.WithDeadline(context.Background(), budget.deadline)
154+ defer cancel()
155+ toks, ok := tokenise(ctx, chroma.Coalesce(lx), code.String())
156+ return toks, lang, ok
157+}
@@ -0,0 +1,157 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "strings"
8+ "time"
9+
10+ "github.com/alecthomas/chroma/v2"
11+ "github.com/alecthomas/chroma/v2/lexers"
12+ "github.com/yuin/goldmark/ast"
13+ "github.com/yuin/goldmark/renderer"
14+ goldhtml "github.com/yuin/goldmark/renderer/html"
15+ "github.com/yuin/goldmark/util"
16+)
17+
18+const (
19+ markdownBlockBytes = 64 << 10
20+ markdownDocumentBytes = 256 << 10
21+ markdownLexTime = 200 * time.Millisecond
22+ markdownBudgetKey = "markdown-lex-budget"
23+)
24+
25+// Budget belongs to the parsed document, never to the shared renderer.
26+// Charge attempts, including failures, so repeated bad blocks cannot bypass it.
27+type markdownLexBudget struct {
28+ bytes, blocks int
29+ deadline time.Time
30+}
31+
32+type fencedRenderer struct{}
33+
34+// Capture goldmark's default renderer rather than duplicate its escaping rules.
35+type fencedFallback struct{ render renderer.NodeRendererFunc }
36+
37+func (f *fencedFallback) Register(k ast.NodeKind, fn renderer.NodeRendererFunc) {
38+ if k == ast.KindFencedCodeBlock {
39+ f.render = fn
40+ }
41+}
42+
43+func (fencedRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
44+ var fallback fencedFallback
45+ goldhtml.NewRenderer().RegisterFuncs(&fallback)
46+ r.Register(ast.KindFencedCodeBlock, func(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
47+ n := node.(*ast.FencedCodeBlock)
48+ if !entering {
49+ return ast.WalkContinue, nil
50+ }
51+ toks, lang, ok := markdownTokens(n, source)
52+ if !ok {
53+ // Default rendering is plain escaped text. Omit unsafe language classes.
54+ if !safeMarkdownLanguage(string(n.Language(source))) {
55+ plain := ast.NewFencedCodeBlock(nil)
56+ plain.SetLines(n.Lines())
57+ n = plain
58+ }
59+ _, err := fallback.render(w, source, n, true)
60+ if err != nil {
61+ return ast.WalkStop, err
62+ }
63+ return fallback.render(w, source, n, false)
64+ }
65+ _, _ = w.WriteString(`<pre class="chroma"><code class="language-` + lang + `">`)
66+ // EnsureNL lexers may append a newline: never emit bytes not in the block.
67+ remaining := 0
68+ for i := 0; i < n.Lines().Len(); i++ {
69+ line := n.Lines().At(i)
70+ remaining += len(line.Value(source))
71+ }
72+ for _, tk := range toks {
73+ v := tk.Value
74+ if len(v) > remaining {
75+ v = v[:remaining]
76+ }
77+ remaining -= len(v)
78+ if v == "" {
79+ continue
80+ }
81+ c := tokenClass(tk.Type)
82+ if c != "" {
83+ _, _ = w.WriteString(`<span class="` + c + `">`)
84+ }
85+ writeMarked(w, v)
86+ if c != "" {
87+ _, _ = w.WriteString(`</span>`)
88+ }
89+ }
90+ _, _ = w.WriteString("</code></pre>\n")
91+ return ast.WalkSkipChildren, nil
92+ })
93+}
94+
95+func safeMarkdownLanguage(s string) bool {
96+ if s == "" {
97+ return false
98+ }
99+ for _, c := range s {
100+ switch {
101+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', strings.ContainsRune("_+.#-", c):
102+ default:
103+ return false
104+ }
105+ }
106+ return true
107+}
108+
109+func markdownTokens(n *ast.FencedCodeBlock, source []byte) ([]chroma.Token, string, bool) {
110+ var lang string
111+ if n.Info != nil {
112+ if words := strings.Fields(string(n.Info.Segment.Value(source))); len(words) > 0 {
113+ lang = words[0]
114+ }
115+ }
116+ if !safeMarkdownLanguage(lang) {
117+ return nil, "", false
118+ }
119+ value, _ := n.OwnerDocument().AttributeString(markdownBudgetKey)
120+ budget, ok := value.(*markdownLexBudget)
121+ if !ok || budget.blocks >= 128 || time.Now().After(budget.deadline) {
122+ return nil, "", false
123+ }
124+ size := 0
125+ for i := 0; i < n.Lines().Len(); i++ {
126+ line := n.Lines().At(i)
127+ size += len(line.Value(source))
128+ if size > markdownBlockBytes {
129+ return nil, "", false
130+ }
131+ }
132+ if size > markdownDocumentBytes-budget.bytes {
133+ return nil, "", false
134+ }
135+ budget.blocks++
136+ budget.bytes += size
137+ lx := lexers.Get(lang)
138+ if lx == nil {
139+ return nil, "", false
140+ }
141+ select {
142+ case lexSlots <- struct{}{}:
143+ defer func() { <-lexSlots }()
144+ default:
145+ return nil, "", false
146+ }
147+ var code strings.Builder
148+ code.Grow(size)
149+ for i := 0; i < n.Lines().Len(); i++ {
150+ line := n.Lines().At(i)
151+ code.Write(line.Value(source))
152+ }
153+ ctx, cancel := context.WithDeadline(context.Background(), budget.deadline)
154+ defer cancel()
155+ toks, ok := tokenise(ctx, chroma.Coalesce(lx), code.String())
156+ return toks, lang, ok
157+}
M internal/adapters/web/markdown_test.go +64 -0
@@ -232,3 +232,67 @@ func TestNoteMarkdown(t *testing.T) {
232232 assert.NotContains(t, string(h), "<script")
233233 assert.Contains(t, string(h), `title="note 2"`)
234234 }
235+
236+func TestMarkdownFencedHighlight(t *testing.T) {
237+ tests := []struct {
238+ name, info, code string
239+ highlighted bool
240+ }{
241+ {"go", "go", "package main\nfunc main() {}\n", true},
242+ {"info suffix", "go example", "package main\n", true},
243+ {"unknown", "not-a-real-language", "<script>\n", false},
244+ {"empty", "", "<script>\n", false},
245+ {"html", "html", "<script>alert(1)</script>\n", true},
246+ {"injection", `go" onmouseover=x`, "package main\n", false},
247+ {"oversize", "go", strings.Repeat("x", markdownBlockBytes+1) + "\n", false},
248+ }
249+ for _, tt := range tests {
250+ t.Run(tt.name, func(t *testing.T) {
251+ src := "```" + tt.info + "\n" + tt.code + "```\n"
252+ for _, untrusted := range []bool{false, true} {
253+ var h string
254+ if untrusted {
255+ out, err := newMarkdown().renderUntrusted([]byte(src), "test-")
256+ require.NoError(t, err)
257+ h = string(out)
258+ } else {
259+ h, _ = renderMD(t, src)
260+ }
261+ assert.Equal(t, tt.highlighted, strings.Contains(h, `<pre class="chroma">`))
262+ assert.NotContains(t, h, "<script>")
263+ assert.NotContains(t, h, `style=`)
264+ assert.NotContains(t, h, `onmouseover`)
265+ if tt.name == "go" {
266+ assert.Contains(t, h, `<span class="kn">package</span>`)
267+ }
268+ if tt.name == "html" {
269+ assert.Contains(t, h, "&lt;")
270+ assert.Contains(t, h, "script")
271+ }
272+ if !tt.highlighted {
273+ assert.NotContains(t, h, "<span")
274+ assert.Contains(t, h, "<pre><code")
275+ }
276+ }
277+ })
278+ }
279+}
280+
281+func TestMarkdownHighlightMarkers(t *testing.T) {
282+ h, _ := renderMD(t, "```go\n// invisible \u202e\n```\n")
283+ assert.Contains(t, h, `class="ctl"`)
284+ assert.Contains(t, h, "U+202E")
285+ assert.NotContains(t, h, "\u202e")
286+}
287+
288+func TestMarkdownHighlightDocumentBudget(t *testing.T) {
289+ // Even thousands of tiny blocks may only attempt 128 lexes per document.
290+ h, _ := renderMD(t, strings.Repeat("```go\npackage main\n```\n\n", 1000))
291+ assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 128)
292+ assert.Contains(t, h, "<pre><code")
293+ // The byte budget also stops blocks before the block-count budget.
294+ block := "```go\n//" + strings.Repeat("x", markdownBlockBytes-4) + "\n```\n\n"
295+ h, _ = renderMD(t, strings.Repeat(block, 6))
296+ assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 4)
297+ assert.Contains(t, h, "<pre><code")
298+}
@@ -232,3 +232,67 @@ func TestNoteMarkdown(t *testing.T) {
232 assert.NotContains(t, string(h), "<script")232 assert.NotContains(t, string(h), "<script")
233 assert.Contains(t, string(h), `title="note 2"`)233 assert.Contains(t, string(h), `title="note 2"`)
234 }234 }
235+
236+func TestMarkdownFencedHighlight(t *testing.T) {
237+ tests := []struct {
238+ name, info, code string
239+ highlighted bool
240+ }{
241+ {"go", "go", "package main\nfunc main() {}\n", true},
242+ {"info suffix", "go example", "package main\n", true},
243+ {"unknown", "not-a-real-language", "<script>\n", false},
244+ {"empty", "", "<script>\n", false},
245+ {"html", "html", "<script>alert(1)</script>\n", true},
246+ {"injection", `go" onmouseover=x`, "package main\n", false},
247+ {"oversize", "go", strings.Repeat("x", markdownBlockBytes+1) + "\n", false},
248+ }
249+ for _, tt := range tests {
250+ t.Run(tt.name, func(t *testing.T) {
251+ src := "```" + tt.info + "\n" + tt.code + "```\n"
252+ for _, untrusted := range []bool{false, true} {
253+ var h string
254+ if untrusted {
255+ out, err := newMarkdown().renderUntrusted([]byte(src), "test-")
256+ require.NoError(t, err)
257+ h = string(out)
258+ } else {
259+ h, _ = renderMD(t, src)
260+ }
261+ assert.Equal(t, tt.highlighted, strings.Contains(h, `<pre class="chroma">`))
262+ assert.NotContains(t, h, "<script>")
263+ assert.NotContains(t, h, `style=`)
264+ assert.NotContains(t, h, `onmouseover`)
265+ if tt.name == "go" {
266+ assert.Contains(t, h, `<span class="kn">package</span>`)
267+ }
268+ if tt.name == "html" {
269+ assert.Contains(t, h, "&lt;")
270+ assert.Contains(t, h, "script")
271+ }
272+ if !tt.highlighted {
273+ assert.NotContains(t, h, "<span")
274+ assert.Contains(t, h, "<pre><code")
275+ }
276+ }
277+ })
278+ }
279+}
280+
281+func TestMarkdownHighlightMarkers(t *testing.T) {
282+ h, _ := renderMD(t, "```go\n// invisible \u202e\n```\n")
283+ assert.Contains(t, h, `class="ctl"`)
284+ assert.Contains(t, h, "U+202E")
285+ assert.NotContains(t, h, "\u202e")
286+}
287+
288+func TestMarkdownHighlightDocumentBudget(t *testing.T) {
289+ // Even thousands of tiny blocks may only attempt 128 lexes per document.
290+ h, _ := renderMD(t, strings.Repeat("```go\npackage main\n```\n\n", 1000))
291+ assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 128)
292+ assert.Contains(t, h, "<pre><code")
293+ // The byte budget also stops blocks before the block-count budget.
294+ block := "```go\n//" + strings.Repeat("x", markdownBlockBytes-4) + "\n```\n\n"
295+ h, _ = renderMD(t, strings.Repeat(block, 6))
296+ assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 4)
297+ assert.Contains(t, h, "<pre><code")
298+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close