detached fb91573

refactor(web): share one invisible-character masker between code and diff views

commit
fb91573ebf7758a1f96ff23a23ae33722464c2da
author
brice · · 2026-10-10 13:26 UTC
parent
652ddb1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +9 -45
M internal/adapters/web/blob.go +3 -3
@@ -123,7 +123,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in
123123 }
124124 size := b.info.GetSize()
125125 p := BlobPage{
126- Name: markedText(name),
126+ Name: visibleText(name),
127127 RawHref: repoHref(d.name(), "raw", t.name, t.path),
128128 HistHref: repoHref(d.name(), "commits", t.name, t.path),
129129 DirHref: repoHref(d.name(), "tree", t.name, dir),
@@ -187,7 +187,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in
187187 l := s.repoLayout(ctx, d, "tree")
188188 pinRef(&l, t)
189189 l.Crumbs = fileCrumbs(d.name(), "blob", t)
190- l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance
190+ l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
191191 l.Status = Status{
192192 Mode: "blob", Context: p.Name,
193193 Hints: "click a line number · y permalink · ? help", Blob: p.HasCode,
@@ -207,7 +207,7 @@ func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitR
207207 }
208208 c := res.Msg.GetCommits()[0]
209209 row := &CommitRow{
210- Short: short(c.GetId()), Subject: markedText(c.GetSummary()), Author: c.GetAuthor().GetName(),
210+ Short: short(c.GetId()), Subject: visibleText(c.GetSummary()), Author: c.GetAuthor().GetName(),
211211 CHref: repoHref(repo, "commit", c.GetId()),
212212 }
213213 if ts := c.GetCommitter().GetTime(); ts.IsValid() {
@@ -123,7 +123,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in
123 }123 }
124 size := b.info.GetSize()124 size := b.info.GetSize()
125 p := BlobPage{125 p := BlobPage{
126- Name: markedText(name),126+ Name: visibleText(name),
127 RawHref: repoHref(d.name(), "raw", t.name, t.path),127 RawHref: repoHref(d.name(), "raw", t.name, t.path),
128 HistHref: repoHref(d.name(), "commits", t.name, t.path),128 HistHref: repoHref(d.name(), "commits", t.name, t.path),
129 DirHref: repoHref(d.name(), "tree", t.name, dir),129 DirHref: repoHref(d.name(), "tree", t.name, dir),
@@ -187,7 +187,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in
187 l := s.repoLayout(ctx, d, "tree")187 l := s.repoLayout(ctx, d, "tree")
188 pinRef(&l, t)188 pinRef(&l, t)
189 l.Crumbs = fileCrumbs(d.name(), "blob", t)189 l.Crumbs = fileCrumbs(d.name(), "blob", t)
190- l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance190+ l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
191 l.Status = Status{191 l.Status = Status{
192 Mode: "blob", Context: p.Name,192 Mode: "blob", Context: p.Name,
193 Hints: "click a line number · y permalink · ? help", Blob: p.HasCode,193 Hints: "click a line number · y permalink · ? help", Blob: p.HasCode,
@@ -207,7 +207,7 @@ func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitR
207 }207 }
208 c := res.Msg.GetCommits()[0]208 c := res.Msg.GetCommits()[0]
209 row := &CommitRow{209 row := &CommitRow{
210- Short: short(c.GetId()), Subject: markedText(c.GetSummary()), Author: c.GetAuthor().GetName(),210+ Short: short(c.GetId()), Subject: visibleText(c.GetSummary()), Author: c.GetAuthor().GetName(),
211 CHref: repoHref(repo, "commit", c.GetId()),211 CHref: repoHref(repo, "commit", c.GetId()),
212 }212 }
213 if ts := c.GetCommitter().GetTime(); ts.IsValid() {213 if ts := c.GetCommitter().GetTime(); ts.IsValid() {
M internal/adapters/web/highlight.go +1 -32
@@ -204,20 +204,6 @@ var invisibleNames = map[rune]string{
204204 0xFEFF: "byte order mark",
205205 }
206206
207-// isInvisible reports whether r changes how code reads without being seen:
208-// the bidirectional controls of "Trojan Source" attacks (CVE-2021-42574) and
209-// the other format characters, the controls, the line and paragraph
210-// separators and the blank Hangul fillers. A tab is ordinary.
211-func isInvisible(r rune) bool {
212- switch r {
213- case '\t', '\n':
214- return false
215- case 0x115F, 0x1160, 0x3164, 0xFFA0:
216- return true
217- }
218- return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r)
219-}
220-
221207 // codePoint is "U+202E".
222208 func codePoint(r rune) string { return fmt.Sprintf("U+%04X", r) }
223209
@@ -237,7 +223,7 @@ func invisibleName(r rune) string {
237223 // </span>. The character itself is never output.
238224 func writeMarked(b *strings.Builder, s string) {
239225 for {
240- i := strings.IndexFunc(s, isInvisible)
226+ i := strings.IndexFunc(s, invisible)
241227 if i < 0 {
242228 b.WriteString(html.EscapeString(s))
243229 return
@@ -249,20 +235,3 @@ func writeMarked(b *strings.Builder, s string) {
249235 s = s[i+size:]
250236 }
251237 }
252-
253-// markedText is s as plain text with each invisible character replaced by
254-// its <U+XXXX> marker, for names and paths shown outside the code view.
255-func markedText(s string) string {
256- if !strings.ContainsFunc(s, isInvisible) {
257- return s
258- }
259- var b strings.Builder
260- for _, r := range s {
261- if isInvisible(r) {
262- b.WriteString("<" + codePoint(r) + ">")
263- } else {
264- b.WriteRune(r)
265- }
266- }
267- return b.String()
268-}
@@ -204,20 +204,6 @@ var invisibleNames = map[rune]string{
204 0xFEFF: "byte order mark",204 0xFEFF: "byte order mark",
205 }205 }
206 206
207-// isInvisible reports whether r changes how code reads without being seen:
208-// the bidirectional controls of "Trojan Source" attacks (CVE-2021-42574) and
209-// the other format characters, the controls, the line and paragraph
210-// separators and the blank Hangul fillers. A tab is ordinary.
211-func isInvisible(r rune) bool {
212- switch r {
213- case '\t', '\n':
214- return false
215- case 0x115F, 0x1160, 0x3164, 0xFFA0:
216- return true
217- }
218- return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r)
219-}
220-
221 // codePoint is "U+202E".207 // codePoint is "U+202E".
222 func codePoint(r rune) string { return fmt.Sprintf("U+%04X", r) }208 func codePoint(r rune) string { return fmt.Sprintf("U+%04X", r) }
223 209
@@ -237,7 +223,7 @@ func invisibleName(r rune) string {
237 // </span>. The character itself is never output.223 // </span>. The character itself is never output.
238 func writeMarked(b *strings.Builder, s string) {224 func writeMarked(b *strings.Builder, s string) {
239 for {225 for {
240- i := strings.IndexFunc(s, isInvisible)226+ i := strings.IndexFunc(s, invisible)
241 if i < 0 {227 if i < 0 {
242 b.WriteString(html.EscapeString(s))228 b.WriteString(html.EscapeString(s))
243 return229 return
@@ -249,20 +235,3 @@ func writeMarked(b *strings.Builder, s string) {
249 s = s[i+size:]235 s = s[i+size:]
250 }236 }
251 }237 }
252-
253-// markedText is s as plain text with each invisible character replaced by
254-// its <U+XXXX> marker, for names and paths shown outside the code view.
255-func markedText(s string) string {
256- if !strings.ContainsFunc(s, isInvisible) {
257- return s
258- }
259- var b strings.Builder
260- for _, r := range s {
261- if isInvisible(r) {
262- b.WriteString("<" + codePoint(r) + ">")
263- } else {
264- b.WriteRune(r)
265- }
266- }
267- return b.String()
268-}
M internal/adapters/web/highlight_test.go +0 -5
@@ -59,11 +59,6 @@ func TestHighlightMarksInvisibleCharacters(t *testing.T) {
5959 assert.Contains(t, h, "&lt;U+202E&gt;")
6060 }
6161
62-func TestMarkedText(t *testing.T) {
63- assert.Equal(t, "plain.go", markedText("plain.go"))
64- assert.Equal(t, "a<U+202E>b", markedText("a\u202eb"))
65-}
66-
6762 // The lexers do not lose or add characters: the text of the lines is the
6863 // source, whatever the language.
6964 func TestHighlightKeepsTheSource(t *testing.T) {
@@ -59,11 +59,6 @@ func TestHighlightMarksInvisibleCharacters(t *testing.T) {
59 assert.Contains(t, h, "&lt;U+202E&gt;")59 assert.Contains(t, h, "&lt;U+202E&gt;")
60 }60 }
61 61
62-func TestMarkedText(t *testing.T) {
63- assert.Equal(t, "plain.go", markedText("plain.go"))
64- assert.Equal(t, "a<U+202E>b", markedText("a\u202eb"))
65-}
66-
67 // The lexers do not lose or add characters: the text of the lines is the62 // The lexers do not lose or add characters: the text of the lines is the
68 // source, whatever the language.63 // source, whatever the language.
69 func TestHighlightKeepsTheSource(t *testing.T) {64 func TestHighlightKeepsTheSource(t *testing.T) {
M internal/adapters/web/tree.go +5 -5
@@ -107,7 +107,7 @@ func fileCrumbs(repo, kind string, t target) []Crumb {
107107 segs := strings.Split(t.path, "/")
108108 for i, seg := range segs {
109109 last := i == len(segs)-1
110- c := Crumb{Label: markedText(seg), Current: last}
110+ c := Crumb{Label: visibleText(seg), Current: last}
111111 if !last || kind == "tree" {
112112 c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/"))
113113 } else {
@@ -166,7 +166,7 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
166166 p := TreePage{}
167167 l := s.repoLayout(ctx, d, "tree")
168168 for i, e := range entries {
169- row := TreeRow{Glyph: "·", Name: markedText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0}
169+ row := TreeRow{Glyph: "·", Name: visibleText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0}
170170 switch e.GetKind() {
171171 case pb.EntryKind_ENTRY_KIND_DIRECTORY:
172172 row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-"
@@ -179,11 +179,11 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
179179 default:
180180 row.Href = repoHref(d.name(), "blob", t.name, e.GetPath())
181181 if len(l.Cmd) < maxFileTargets {
182- l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: markedText(e.GetPath()), Href: row.Href})
182+ l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: visibleText(e.GetPath()), Href: row.Href})
183183 }
184184 }
185185 if c := e.GetLastCommit(); c != nil {
186- row.Subject, row.Author = markedText(c.GetSummary()), c.GetAuthor().GetName()
186+ row.Subject, row.Author = visibleText(c.GetSummary()), c.GetAuthor().GetName()
187187 row.CHref = repoHref(d.name(), "commit", c.GetId())
188188 if ts := c.GetCommitter().GetTime(); ts.IsValid() {
189189 row.When, row.WhenAt = ago(now, ts.AsTime()), rfc3339(ts.AsTime())
@@ -210,7 +210,7 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
210210 if t.path == "" {
211211 l.Title = "tree · " + d.name() + " · " + s.instance
212212 } else {
213- l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance
213+ l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
214214 }
215215 l.Status = Status{
216216 Mode: "tree", Context: "tree · " + plural(len(entries), "entry", "entries"),
@@ -107,7 +107,7 @@ func fileCrumbs(repo, kind string, t target) []Crumb {
107 segs := strings.Split(t.path, "/")107 segs := strings.Split(t.path, "/")
108 for i, seg := range segs {108 for i, seg := range segs {
109 last := i == len(segs)-1109 last := i == len(segs)-1
110- c := Crumb{Label: markedText(seg), Current: last}110+ c := Crumb{Label: visibleText(seg), Current: last}
111 if !last || kind == "tree" {111 if !last || kind == "tree" {
112 c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/"))112 c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/"))
113 } else {113 } else {
@@ -166,7 +166,7 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
166 p := TreePage{}166 p := TreePage{}
167 l := s.repoLayout(ctx, d, "tree")167 l := s.repoLayout(ctx, d, "tree")
168 for i, e := range entries {168 for i, e := range entries {
169- row := TreeRow{Glyph: "·", Name: markedText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0}169+ row := TreeRow{Glyph: "·", Name: visibleText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0}
170 switch e.GetKind() {170 switch e.GetKind() {
171 case pb.EntryKind_ENTRY_KIND_DIRECTORY:171 case pb.EntryKind_ENTRY_KIND_DIRECTORY:
172 row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-"172 row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-"
@@ -179,11 +179,11 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
179 default:179 default:
180 row.Href = repoHref(d.name(), "blob", t.name, e.GetPath())180 row.Href = repoHref(d.name(), "blob", t.name, e.GetPath())
181 if len(l.Cmd) < maxFileTargets {181 if len(l.Cmd) < maxFileTargets {
182- l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: markedText(e.GetPath()), Href: row.Href})182+ l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: visibleText(e.GetPath()), Href: row.Href})
183 }183 }
184 }184 }
185 if c := e.GetLastCommit(); c != nil {185 if c := e.GetLastCommit(); c != nil {
186- row.Subject, row.Author = markedText(c.GetSummary()), c.GetAuthor().GetName()186+ row.Subject, row.Author = visibleText(c.GetSummary()), c.GetAuthor().GetName()
187 row.CHref = repoHref(d.name(), "commit", c.GetId())187 row.CHref = repoHref(d.name(), "commit", c.GetId())
188 if ts := c.GetCommitter().GetTime(); ts.IsValid() {188 if ts := c.GetCommitter().GetTime(); ts.IsValid() {
189 row.When, row.WhenAt = ago(now, ts.AsTime()), rfc3339(ts.AsTime())189 row.When, row.WhenAt = ago(now, ts.AsTime()), rfc3339(ts.AsTime())
@@ -210,7 +210,7 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
210 if t.path == "" {210 if t.path == "" {
211 l.Title = "tree · " + d.name() + " · " + s.instance211 l.Title = "tree · " + d.name() + " · " + s.instance
212 } else {212 } else {
213- l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance213+ l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
214 }214 }
215 l.Status = Status{215 l.Status = Status{
216 Mode: "tree", Context: "tree · " + plural(len(entries), "entry", "entries"),216 Mode: "tree", Context: "tree · " + plural(len(entries), "entry", "entries"),

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 45

↑↓ moveenter openesc close