v0.7.0 45bfc5b

blob.go RawHistoryFolder

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

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 4

Tags 45

↑↓ moveenter openesc close