v0.3.0 9fdf386

blob.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"fmt"
8	"html/template"
9	"net/http"
10	"path"
11	"strings"
12
13	"connectrpc.com/connect"
14	"github.com/a-h/templ"
15	"github.com/bnema/zerowrap"
16
17	pb "bnema.dev/ermite-api/gen/ermite"
18)
19
20const (
21	// maxCodeBytes is the largest file shown in the page; a larger one gets
22	// a notice and the link to raw.
23	maxCodeBytes = 1 << 20
24	// maxRenderBytes is the largest Markdown file that is rendered; above it
25	// the file is shown as source only.
26	maxRenderBytes = 512 << 10
27)
28
29// BlobPage is a file.
30type BlobPage struct {
31	Name   string
32	Notice string
33	// Code is the highlighted source, shown when HasCode.
34	Code    template.HTML
35	HasCode bool
36	// Rendered is a Markdown file as HTML; the source is under a fold.
37	Rendered *Readme
38	Outline  []Heading
39	// Image says the file is shown as an image loaded from RawHref.
40	Image    bool
41	Facts    []Fact
42	Last     *CommitRow
43	RawHref  string
44	HistHref string
45	DirHref  string
46}
47
48// blobStream is an open GetBlob stream whose first message has been read.
49type blobStream struct {
50	stream *connect.ServerStreamForClient[pb.GetBlobResponse]
51	info   *pb.BlobInfo
52	// buf holds the content already received and not yet handed out.
53	buf []byte
54}
55
56// openBlob starts the stream of a file and reads its metadata.
57func (s *Server) openBlob(ctx context.Context, repo string, t target) (*blobStream, error) {
58	if t.path == "" {
59		return nil, errNotFound
60	}
61	st, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: t.rev, Path: t.path}))
62	if err != nil {
63		return nil, err
64	}
65	if !st.Receive() {
66		err := st.Err()
67		_ = st.Close()
68		if err == nil {
69			err = errNotFound
70		}
71		return nil, err
72	}
73	m := st.Msg()
74	return &blobStream{stream: st, info: m.GetInfo(), buf: append([]byte(nil), m.GetChunk()...)}, nil
75}
76
77func (b *blobStream) close() { _ = b.stream.Close() }
78
79// fill reads from the stream until buf holds n bytes or the content ends.
80func (b *blobStream) fill(n int) error {
81	for len(b.buf) < n && b.stream.Receive() {
82		b.buf = append(b.buf, b.stream.Msg().GetChunk()...)
83	}
84	return b.stream.Err()
85}
86
87// image types: the only images the pages show and raw serves as images.
88var imageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true}
89
90// sniffImage is the image type of the first bytes of a file, "" when it is
91// not a PNG, JPEG, GIF or WebP. SVG is never one: it can carry scripts.
92func sniffImage(head []byte) string {
93	if len(head) > 512 {
94		head = head[:512]
95	}
96	if t := http.DetectContentType(head); imageTypes[t] {
97		return t
98	}
99	return ""
100}
101
102// blob shows a file: highlighted source, rendered Markdown with its source
103// under a fold, an image, or a notice when the file is too large or binary.
104func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, int, error) {
105	d, err := s.loadRepo(ctx, r.PathValue("repo"))
106	if err != nil {
107		return nil, 0, err
108	}
109	t, ok := d.locate(r.PathValue("rest"))
110	if !ok {
111		return nil, 0, errNotFound
112	}
113	b, err := s.openBlob(ctx, d.name(), t)
114	if err != nil {
115		return nil, 0, err
116	}
117	defer b.close()
118
119	name := path.Base(t.path)
120	dir := path.Dir(t.path)
121	if dir == "." {
122		dir = ""
123	}
124	size := b.info.GetSize()
125	p := BlobPage{
126		Name:     visibleText(name),
127		RawHref:  repoHref(d.name(), "raw", t.name, t.path),
128		HistHref: repoHref(d.name(), "commits", t.name, t.path),
129		DirHref:  repoHref(d.name(), "tree", t.name, dir),
130		Facts:    []Fact{{Key: "size", Val: humanSize(size)}},
131	}
132	typ := b.info.GetMediaType()
133	lang := ""
134
135	// An image is recognised by its bytes, whatever Git's text rule says.
136	if err := b.fill(512); err != nil {
137		return nil, 0, err
138	}
139	img := sniffImage(b.buf)
140
141	var src []byte
142	switch {
143	case img != "":
144		p.Image, typ = true, img
145	case b.info.GetBinary():
146		p.Notice = fmt.Sprintf("binary file (%s, %s): not shown. use raw.", typ, humanSize(size))
147	case size > maxCodeBytes:
148		p.Notice = fmt.Sprintf("file too large to show (%s, the limit is %s). use raw.", humanSize(size), humanSize(maxCodeBytes))
149	case size == 0:
150		p.Notice = "empty file."
151	default:
152		if err := b.fill(int(size)); err != nil {
153			return nil, 0, err
154		}
155		src = b.buf
156	}
157
158	if src != nil {
159		text := string(src)
160		lines := countLines(src)
161		p.Facts = append(p.Facts, Fact{Key: "lines", Val: itoa(lines)})
162		if lines > maxViewLines {
163			text = firstLines(text, maxViewLines)
164			p.Notice = fmt.Sprintf("%d lines: showing the first %d. use raw for the rest.", lines, maxViewLines)
165		}
166		cv := highlightCtx(ctx, t.path, text)
167		p.Code, p.HasCode, lang = cv.html, true, cv.lang
168		if isMarkdown(name) && size <= maxRenderBytes {
169			h, outline, err := s.md.render(src, d.name(), t.name, dir)
170			if err == nil {
171				p.Rendered, p.Outline = &Readme{Name: p.Name, HTML: h}, outline
172			} else {
173				log := zerowrap.FromCtx(ctx)
174				log.Warn().Err(err).Str("repo", d.name()).Msg("render Markdown file, showing source")
175			}
176		}
177	}
178	if lang == "" {
179		lang = typ
180	}
181	p.Facts = append(p.Facts, Fact{Key: "type", Val: lang})
182
183	if last := s.lastChange(ctx, d.name(), t); last != nil {
184		p.Last = last
185	}
186
187	l := s.repoLayout(ctx, d, "tree")
188	pinRef(&l, t)
189	l.Crumbs = pathCrumbs(d.name(), "blob", t)
190	l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
191	l.Status = Status{
192		Mode: "blob", Context: p.Name,
193		Hints: "click a line number · y permalink · ? help", Blob: p.HasCode,
194	}
195	l.HasRail, l.Read, l.RailSum = true, p.Rendered != nil, "file · last change · selection"
196	return Blob(l, p), http.StatusOK, nil
197}
198
199// lastChange is the latest commit that touched the path, nil when it cannot
200// be found (the page does not depend on it).
201func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitRow {
202	res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
203		Repo: repo, Revision: t.rev, Path: t.path, PageSize: 1,
204	}))
205	if err != nil || len(res.Msg.GetCommits()) != 1 {
206		return nil
207	}
208	row := commitRow(repo, res.Msg.GetCommits()[0], s.cfg.Now())
209	return &row
210}
211
212// isMarkdown says whether a file name is a Markdown file.
213func isMarkdown(name string) bool {
214	n := strings.ToLower(name)
215	return strings.HasSuffix(n, ".md") || strings.HasSuffix(n, ".markdown")
216}
217
218// firstLines is the first n lines of s.
219func firstLines(s string, n int) string {
220	idx := 0
221	for i := 0; i < n; i++ {
222		j := strings.IndexByte(s[idx:], '\n')
223		if j < 0 {
224			return s
225		}
226		idx += j + 1
227	}
228	return s[:idx]
229}

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