all repos

mugit @ 9d3f4dd63057f8e61e6bd90be32e6fbedf1d599b

🐮 git server that your cow will love

mugit/internal/handlers/repo.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
ui: add breadcrumbs to file content view, 3 months ago
1
package handlers
2
3
import (
4
	"bytes"
5
	"errors"
6
	"fmt"
7
	"html/template"
8
	"io"
9
	"log/slog"
10
	"net/http"
11
	"os"
12
	"path/filepath"
13
	"sort"
14
	"strconv"
15
	"strings"
16
	"time"
17
18
	"olexsmir.xyz/mugit/internal/git"
19
	"olexsmir.xyz/mugit/internal/markdown"
20
)
21
22
type Meta struct {
23
	Title       string
24
	Description string
25
	Host        string
26
	IsEmpty     bool
27
	GoMod       bool
28
	SSHEnabled  bool
29
}
30
31
type RepoBase struct {
32
	Ref  string
33
	Desc string
34
}
35
36
type PageData[T any] struct {
37
	Meta     Meta
38
	RepoName string // empty for non-repo pages, needed for _head.html to  compile
39
	P        T
40
}
41
42
func (h *handlers) indexHandler(w http.ResponseWriter, r *http.Request) {
43
	repos, err := h.listPublicRepos()
44
	if err != nil {
45
		h.write500(w, err)
46
		return
47
	}
48
	h.templ(w, "index", h.pageData(nil, repos))
49
}
50
51
type RepoIndex struct {
52
	Desc           string
53
	IsEmpty        bool
54
	Readme         template.HTML
55
	Ref            string
56
	Commits        []*git.Commit
57
	IsMirror       bool
58
	MirrorURL      string
59
	MirrorLastSync time.Time
60
}
61
62
func (h *handlers) repoIndexHandler(w http.ResponseWriter, r *http.Request) {
63
	repo, err := h.openPublicRepo(r.PathValue("name"), "")
64
	if err != nil {
65
		h.write404(w, err)
66
		return
67
	}
68
69
	desc, err := repo.Description()
70
	if err != nil {
71
		h.write500(w, err)
72
		return
73
	}
74
75
	p := RepoIndex{Desc: desc, IsEmpty: repo.IsEmpty()}
76
	if p.IsEmpty {
77
		h.templ(w, "repo_index", h.pageData(repo, p))
78
		return
79
	}
80
81
	p.Ref, err = repo.FindMasterBranch(h.c.Repo.Masters)
82
	if err != nil {
83
		h.write500(w, err)
84
		return
85
	}
86
87
	p.Readme, err = h.renderReadme(repo, p.Ref, "")
88
	if err != nil {
89
		h.write500(w, err)
90
		return
91
	}
92
93
	p.Commits, err = repo.Commits("")
94
	if err != nil {
95
		h.write500(w, err)
96
		return
97
	}
98
99
	if len(p.Commits) >= 3 {
100
		p.Commits = p.Commits[:3:3]
101
	}
102
103
	if isMirror, err := repo.IsMirror(); isMirror && err == nil {
104
		p.IsMirror = true
105
		p.MirrorURL, _ = repo.RemoteURL()
106
		p.MirrorLastSync, _ = repo.LastSync()
107
	}
108
109
	h.templ(w, "repo_index", h.pageData(repo, p))
110
}
111
112
type RepoTree struct {
113
	Desc       string
114
	Ref        string
115
	Tree       []git.NiceTree
116
	ParentPath string
117
	DotDot     string
118
	Readme     template.HTML
119
}
120
121
func (h *handlers) repoTreeHandler(w http.ResponseWriter, r *http.Request) {
122
	name := r.PathValue("name")
123
	ref := h.parseRef(r.PathValue("ref"))
124
	treePath := r.PathValue("rest")
125
126
	repo, err := h.openPublicRepo(name, ref)
127
	if err != nil {
128
		h.write404(w, err)
129
		return
130
	}
131
132
	desc, err := repo.Description()
133
	if err != nil {
134
		h.write500(w, err)
135
		return
136
	}
137
138
	tree, err := repo.FileTree(treePath)
139
	if err != nil {
140
		h.write500(w, err)
141
		return
142
	}
143
144
	readme, err := h.renderReadme(repo, ref, treePath)
145
	if err != nil {
146
		h.write500(w, err)
147
		return
148
	}
149
150
	h.templ(w, "repo_tree", h.pageData(repo, RepoTree{
151
		Desc:       desc,
152
		Ref:        ref,
153
		Tree:       tree,
154
		ParentPath: treePath,
155
		DotDot:     filepath.Dir(treePath),
156
		Readme:     readme,
157
	}))
158
}
159
160
type RepoFile struct {
161
	Ref         string
162
	Desc        string
163
	LineCount   []int
164
	Breadcrumbs []Breadcrumb
165
	Path        string
166
	IsImage     bool
167
	IsBinary    bool
168
	Content     string
169
	Mime        string
170
	Size        int64
171
}
172
173
func (h *handlers) fileContentsHandler(w http.ResponseWriter, r *http.Request) {
174
	name := r.PathValue("name")
175
	ref := h.parseRef(r.PathValue("ref"))
176
	treePath := r.PathValue("rest")
177
178
	var raw bool
179
	if rawParam, err := strconv.ParseBool(r.URL.Query().Get("raw")); err == nil {
180
		raw = rawParam
181
	}
182
183
	repo, err := h.openPublicRepo(name, ref)
184
	if err != nil {
185
		h.write404(w, err)
186
		return
187
	}
188
189
	fc, err := repo.FileContent(treePath)
190
	if err != nil {
191
		if errors.Is(err, git.ErrFileNotFound) {
192
			h.write404(w, err)
193
			return
194
		}
195
		h.write500(w, err)
196
		return
197
	}
198
199
	if raw {
200
		w.Header().Set("Content-Type", fc.Mime)
201
		w.WriteHeader(http.StatusOK)
202
		w.Write(fc.Content)
203
		return
204
	}
205
206
	p := RepoFile{
207
		Ref:      ref,
208
		Path:     treePath,
209
		IsImage:  fc.IsImage,
210
		IsBinary: fc.IsBinary,
211
		Mime:     fc.Mime,
212
		Size:     fc.Size,
213
	}
214
215
	p.Desc, err = repo.Description()
216
	if err != nil {
217
		h.write500(w, err)
218
		return
219
	}
220
221
	if !fc.IsImage && !fc.IsBinary {
222
		contentStr := fc.String()
223
		lc, err := countLines(strings.NewReader(contentStr))
224
		if err != nil {
225
			slog.Error("failed to count line numbers", "err", err)
226
		}
227
		lines := make([]int, lc)
228
		for i := range lines {
229
			lines[i] = i + 1
230
		}
231
		p.Content = contentStr
232
		p.LineCount = lines // TODO: replace with strings.Count(, "\n")
233
		p.Breadcrumbs = Breadcrumbs(treePath)
234
	}
235
236
	h.templ(w, "repo_file", h.pageData(repo, p))
237
}
238
239
type RepoLog struct {
240
	Desc      string
241
	Commits   []*git.Commit
242
	Ref       string
243
	NextAfter string
244
}
245
246
func (h *handlers) logHandler(w http.ResponseWriter, r *http.Request) {
247
	name := r.PathValue("name")
248
	ref := h.parseRef(r.PathValue("ref"))
249
	after := r.URL.Query().Get("after")
250
251
	repo, err := h.openPublicRepo(name, ref)
252
	if err != nil {
253
		h.write404(w, err)
254
		return
255
	}
256
257
	desc, err := repo.Description()
258
	if err != nil {
259
		h.write500(w, err)
260
		return
261
	}
262
263
	commits, err := repo.Commits(after)
264
	if err != nil {
265
		h.write500(w, err)
266
		return
267
	}
268
269
	// if we got full page of commits, we probably have more.
270
	// NOTE: this has an edge case, when last page is len(git.CommitsPage), "load more" would be shown
271
	nextAfter := ""
272
	if len(commits) == git.CommitsPage && len(commits) > 0 {
273
		nextAfter = commits[len(commits)-1].HashShort
274
	}
275
276
	h.templ(w, "repo_log", h.pageData(repo, RepoLog{
277
		Desc:      desc,
278
		Ref:       ref,
279
		Commits:   commits,
280
		NextAfter: nextAfter,
281
	}))
282
}
283
284
type RepoCommit struct {
285
	Diff *git.NiceDiff
286
	Ref  string
287
	Desc string
288
}
289
290
func (h *handlers) commitHandler(w http.ResponseWriter, r *http.Request) {
291
	name := r.PathValue("name")
292
	ref := h.parseRef(r.PathValue("ref"))
293
294
	repo, err := h.openPublicRepo(name, ref)
295
	if err != nil {
296
		h.write404(w, err)
297
		return
298
	}
299
300
	diff, err := h.getDiff(repo, ref)
301
	if err != nil {
302
		h.write500(w, err)
303
		return
304
	}
305
306
	desc, err := repo.Description()
307
	if err != nil {
308
		h.write500(w, err)
309
		return
310
	}
311
312
	h.templ(w, "repo_commit", h.pageData(repo, RepoCommit{
313
		Desc: desc,
314
		Ref:  ref,
315
		Diff: diff,
316
	}))
317
}
318
319
type RepoRefs struct {
320
	Desc     string
321
	Ref      string
322
	Branches []*git.Branch
323
	Tags     []*git.TagReference
324
}
325
326
func (h *handlers) refsHandler(w http.ResponseWriter, r *http.Request) {
327
	repo, err := h.openPublicRepo(r.PathValue("name"), "")
328
	if err != nil {
329
		h.write404(w, err)
330
		return
331
	}
332
333
	desc, err := repo.Description()
334
	if err != nil {
335
		h.write500(w, err)
336
		return
337
	}
338
339
	master, err := repo.FindMasterBranch(h.c.Repo.Masters)
340
	if err != nil {
341
		h.write500(w, err)
342
		return
343
	}
344
345
	branches, err := repo.Branches()
346
	if err != nil {
347
		h.write500(w, err)
348
		return
349
	}
350
351
	// repo should have at least one branch, tags are *optional*
352
	tags, _ := repo.Tags()
353
354
	h.templ(w, "repo_refs", h.pageData(repo, RepoRefs{
355
		Desc:     desc,
356
		Ref:      master,
357
		Tags:     tags,
358
		Branches: branches,
359
	}))
360
}
361
362
func countLines(r io.Reader) (int, error) {
363
	buf := make([]byte, 32*1024)
364
	bufLen := 0
365
	count := 0
366
	nl := []byte{'\n'}
367
368
	for {
369
		c, err := r.Read(buf)
370
		if c > 0 {
371
			bufLen += c
372
		}
373
		count += bytes.Count(buf[:c], nl)
374
375
		switch {
376
		case err == io.EOF:
377
			// handle last line not having a newline at the end
378
			if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
379
				count++
380
			}
381
			return count, nil
382
		case err != nil:
383
			return 0, err
384
		}
385
	}
386
}
387
388
type repoList struct {
389
	Name       string
390
	Desc       string
391
	LastCommit time.Time
392
}
393
394
func (h *handlers) listPublicRepos() ([]repoList, error) {
395
	if v, found := h.repoListCache.Get("repo_list"); found {
396
		return v, nil
397
	}
398
399
	dirs, err := os.ReadDir(h.c.Repo.Dir)
400
	if err != nil {
401
		return nil, err
402
	}
403
404
	var repos []repoList
405
	var errs []error
406
	for _, dir := range dirs {
407
		if !dir.IsDir() {
408
			continue
409
		}
410
411
		name := dir.Name()
412
		repo, err := h.openPublicRepo(name, "")
413
		if err != nil {
414
			// if it's not git repo, just ignore it
415
			continue
416
		}
417
418
		desc, err := repo.Description()
419
		if err != nil {
420
			errs = append(errs, err)
421
			continue
422
		}
423
424
		lastCommit, err := repo.LastCommit()
425
		if err != nil {
426
			errs = append(errs, err)
427
			continue
428
		}
429
430
		repos = append(repos, repoList{
431
			Name:       repo.Name(),
432
			Desc:       desc,
433
			LastCommit: lastCommit.Committed,
434
		})
435
	}
436
437
	sort.Slice(repos, func(i, j int) bool {
438
		return repos[j].LastCommit.Before(repos[i].LastCommit)
439
	})
440
441
	h.repoListCache.Set("repo_list", repos)
442
	return repos, errors.Join(errs...)
443
}
444
445
func (h handlers) getDiff(r *git.Repo, ref string) (*git.NiceDiff, error) {
446
	cacheKey := fmt.Sprintf("%s:%s", r.Name(), ref)
447
	if v, found := h.diffCache.Get(cacheKey); found {
448
		return v, nil
449
	}
450
451
	diff, err := r.Diff()
452
	if err != nil {
453
		return nil, err
454
	}
455
456
	h.diffCache.Set(cacheKey, diff)
457
	return diff, nil
458
}
459
460
func (h *handlers) renderReadme(r *git.Repo, ref, treePath string) (template.HTML, error) {
461
	name := r.Name()
462
	cacheKey := fmt.Sprintf("%s:%s:%s", name, ref, treePath)
463
	if v, found := h.readmeCache.Get(cacheKey); found {
464
		return v, nil
465
	}
466
467
	var readmeContents template.HTML
468
	for _, readme := range h.c.Repo.Readmes {
469
		fullPath := filepath.Join(treePath, readme)
470
		fc, ferr := r.FileContent(fullPath)
471
		if ferr != nil {
472
			continue
473
		}
474
475
		if fc.IsBinary && fc.IsImage {
476
			continue
477
		}
478
479
		ext := filepath.Ext(readme)
480
		content := fc.String()
481
		if len(content) > 0 {
482
			switch ext {
483
			case ".md", ".markdown", ".mkd":
484
				readme, err := markdown.Render(name, ref, fullPath, content)
485
				if err != nil {
486
					return "", err
487
				}
488
				return template.HTML(readme), nil
489
490
			default:
491
				readmeContents = template.HTML(fmt.Sprintf(`<pre class="raw">%s</pre>`, content))
492
			}
493
			break
494
		}
495
	}
496
497
	h.readmeCache.Set(cacheKey, readmeContents)
498
	return readmeContents, nil
499
}
500
501
func (h handlers) pageData(repo *git.Repo, p any) PageData[any] {
502
	var name string
503
	var gomod, empty bool
504
	if repo != nil {
505
		gomod = repo.IsGoMod()
506
		empty = repo.IsEmpty()
507
		name = repo.Name()
508
	}
509
510
	return PageData[any]{
511
		P:        p,
512
		RepoName: name,
513
		Meta: Meta{
514
			Title:       h.c.Meta.Title,
515
			Description: h.c.Meta.Description,
516
			Host:        h.c.Meta.Host,
517
			GoMod:       gomod,
518
			SSHEnabled:  h.c.SSH.Enable,
519
			IsEmpty:     empty,
520
		},
521
	}
522
}
523
524
type Breadcrumb struct {
525
	Name   string
526
	Path   string
527
	IsLast bool
528
}
529
530
func Breadcrumbs(path string) []Breadcrumb {
531
	if path == "" {
532
		return nil
533
	}
534
	parts := strings.Split(path, "/")
535
	crumbs := make([]Breadcrumb, len(parts))
536
	for i, part := range parts {
537
		crumbs[i] = Breadcrumb{
538
			Name:   part,
539
			Path:   strings.Join(parts[:i+1], "/"),
540
			IsLast: i == len(parts)-1,
541
		}
542
	}
543
	return crumbs
544
}