cerrado @ 6079b1d963f34ada5c4b25363f2319901e283936

feat: Add error handling
  1diff --git a/pkg/ext/compression.go b/pkg/ext/compression.go
  2index 92144b82132968aa7db6115aad78f37d71c8e354..57ad49ad64f6654f9ec39ea50af972f0739d5e44 100644
  3--- a/pkg/ext/compression.go
  4+++ b/pkg/ext/compression.go
  5@@ -24,7 +24,7 @@ 	innerWriter    http.ResponseWriter
  6 	compressWriter io.Writer
  7 }
  8 
  9-func Compress(next func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
 10+func Compress(next http.HandlerFunc) http.HandlerFunc {
 11 	return func(w http.ResponseWriter, r *http.Request) {
 12 		if accept, ok := r.Header["Accept-Encoding"]; ok {
 13 			if compress, algo := GetCompressionWriter(u.FirstOrZero(accept), w); algo != "" {
 14diff --git a/pkg/ext/log.go b/pkg/ext/log.go
 15new file mode 100644
 16index 0000000000000000000000000000000000000000..a9d26a9afd92ce2309d6f27653577a97a9b76c1e
 17--- /dev/null
 18+++ b/pkg/ext/log.go
 19@@ -0,0 +1,53 @@
 20+package ext
 21+
 22+import (
 23+	"log/slog"
 24+	"net/http"
 25+	"time"
 26+)
 27+
 28+type statusWraper struct {
 29+	statusCode  int
 30+	innerWriter http.ResponseWriter
 31+}
 32+
 33+func (s *statusWraper) Header() http.Header {
 34+	return s.innerWriter.Header()
 35+}
 36+
 37+func (s *statusWraper) Write(b []byte) (int, error) {
 38+	return s.innerWriter.Write(b)
 39+}
 40+
 41+func (s *statusWraper) WriteHeader(statusCode int) {
 42+	s.statusCode = statusCode
 43+	s.innerWriter.WriteHeader(statusCode)
 44+}
 45+
 46+func (s *statusWraper) StatusCode() int {
 47+	if s.statusCode == 0 {
 48+		return 200
 49+	}
 50+	return s.statusCode
 51+}
 52+
 53+func wrap(w http.ResponseWriter) *statusWraper {
 54+	return &statusWraper{
 55+		innerWriter: w,
 56+	}
 57+}
 58+
 59+func Log(next http.HandlerFunc) http.HandlerFunc {
 60+	return func(w http.ResponseWriter, r *http.Request) {
 61+		t := time.Now()
 62+		s := wrap(w)
 63+		next(s, r)
 64+		slog.Info(
 65+			"Http request",
 66+			"method", r.Method,
 67+			"code", s.StatusCode(),
 68+			"path", r.URL,
 69+			"elapsed", time.Since(t),
 70+		)
 71+	}
 72+}
 73diff --git a/pkg/ext/router.go b/pkg/ext/router.go
 74new file mode 100644
 75index 0000000000000000000000000000000000000000..5d22814e9d53c5a65f4f72c6cdf67be9f929bcbb
 76--- /dev/null
 77+++ b/pkg/ext/router.go
 78@@ -0,0 +1,72 @@
 79+package ext
 80+
 81+import (
 82+	"errors"
 83+	"fmt"
 84+	"net/http"
 85+
 86+	"git.gabrielgio.me/cerrado/pkg/service"
 87+	"git.gabrielgio.me/cerrado/templates"
 88+)
 89+
 90+type (
 91+	Router struct {
 92+		middlewares []Middleware
 93+		router      *http.ServeMux
 94+	}
 95+	Middleware          func(next http.HandlerFunc) http.HandlerFunc
 96+	ErrorRequestHandler func(w http.ResponseWriter, r *http.Request) error
 97+)
 98+
 99+func NewRouter() *Router {
100+	return &Router{
101+		router: http.NewServeMux(),
102+	}
103+}
104+func (r *Router) Handler() http.Handler {
105+	return r.router
106+}
107+
108+func (r *Router) AddMiddleware(middleware Middleware) {
109+	r.middlewares = append(r.middlewares, middleware)
110+}
111+
112+func wrapError(next ErrorRequestHandler) http.HandlerFunc {
113+	return func(w http.ResponseWriter, r *http.Request) {
114+		if err := next(w, r); err != nil {
115+			if errors.Is(err, service.RepositoryNotFoundErr) {
116+				NotFound(w)
117+			} else {
118+				InternalServerError(w, err)
119+			}
120+		}
121+	}
122+}
123+
124+func (r *Router) run(next ErrorRequestHandler) http.HandlerFunc {
125+	return func(w http.ResponseWriter, re *http.Request) {
126+		req := wrapError(next)
127+		for _, r := range r.middlewares {
128+			req = r(req)
129+		}
130+		req(w, re)
131+	}
132+}
133+
134+func (r *Router) HandleFunc(path string, handler ErrorRequestHandler) {
135+	r.router.HandleFunc(path, r.run(handler))
136+}
137+
138+func NotFound(w http.ResponseWriter) {
139+	w.WriteHeader(http.StatusNotFound)
140+	templates.WritePageTemplate(w, &templates.ErrorPage{
141+		Message: "Not Found",
142+	})
143+}
144+
145+func InternalServerError(w http.ResponseWriter, err error) {
146+	w.WriteHeader(http.StatusInternalServerError)
147+	templates.WritePageTemplate(w, &templates.ErrorPage{
148+		Message: fmt.Sprintf("Internal Server Error:\n%s", err.Error()),
149+	})
150+}
151diff --git a/pkg/git/git.go b/pkg/git/git.go
152index 6a7b91fcba4060e8c2462a6ff03cf9fd309218d4..ad5d3bca28fe075da5da0ea6ae72cb87bd09861a 100644
153--- a/pkg/git/git.go
154+++ b/pkg/git/git.go
155@@ -99,9 +99,6 @@ 			return nil, err
156 		}
157 		commits = append(commits, c)
158 	}
159-	if err != nil {
160-		return nil, err
161-	}
162 
163 	return commits, nil
164 }
165diff --git a/pkg/handler/about/handler.go b/pkg/handler/about/handler.go
166index 1acde601c7aa036550ab6e73253f7c820646e1e6..ac3d31417836e98121bd03807b2993fa2dbf27cb 100644
167--- a/pkg/handler/about/handler.go
168+++ b/pkg/handler/about/handler.go
169@@ -2,7 +2,6 @@ package about
170 
171 import (
172 	"io"
173-	"log/slog"
174 	"net/http"
175 	"os"
176 
177@@ -27,17 +26,15 @@ func NewAboutHandler(configRepo configurationRepository) *AboutHandler {
178 	return &AboutHandler{configRepo.GetRootReadme()}
179 }
180 
181-func (g *AboutHandler) About(w http.ResponseWriter, _ *http.Request) {
182+func (g *AboutHandler) About(w http.ResponseWriter, _ *http.Request) error {
183 	f, err := os.Open(g.readmePath)
184 	if err != nil {
185-		slog.Error("Error loading readme file", "error", err)
186-		return
187+		return err
188 	}
189 
190 	bs, err := io.ReadAll(f)
191 	if err != nil {
192-		slog.Error("Error reading readme file bytes", "error", err)
193-		return
194+		return err
195 	}
196 
197 	extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
198@@ -54,4 +51,5 @@ 	gitList := &templates.AboutPage{
199 		Body: bs,
200 	}
201 	templates.WritePageTemplate(w, gitList)
202+	return nil
203 }
204diff --git a/pkg/handler/config/handler.go b/pkg/handler/config/handler.go
205index 30f428383db9ada7b0337c2400b983a7d1a4b8c2..c43b54d621d0aed6441a8f2ba85564c0be3da6fe 100644
206--- a/pkg/handler/config/handler.go
207+++ b/pkg/handler/config/handler.go
208@@ -3,7 +3,6 @@
209 import (
210 	"bytes"
211 	"encoding/json"
212-	"log/slog"
213 	"net/http"
214 
215 	"github.com/alecthomas/chroma/v2/formatters/html"
216@@ -11,6 +10,7 @@ 	"github.com/alecthomas/chroma/v2/lexers"
217 	"github.com/alecthomas/chroma/v2/styles"
218 
219 	"git.gabrielgio.me/cerrado/pkg/config"
220+	"git.gabrielgio.me/cerrado/pkg/ext"
221 	"git.gabrielgio.me/cerrado/templates"
222 )
223 
224@@ -21,8 +21,8 @@ 		List() []*config.GitRepositoryConfiguration
225 	}
226 )
227 
228-func ConfigFile(configRepo configurationRepository) func(http.ResponseWriter, *http.Request) {
229-	return func(w http.ResponseWriter, _ *http.Request) {
230+func ConfigFile(configRepo configurationRepository) ext.ErrorRequestHandler {
231+	return func(w http.ResponseWriter, _ *http.Request) error {
232 
233 		config := struct {
234 			RootReadme   string
235@@ -34,8 +34,7 @@ 		}
236 
237 		b, err := json.MarshalIndent(config, "", "  ")
238 		if err != nil {
239-			slog.Error("Error parsing json", "error", err)
240-			return
241+			return err
242 		}
243 
244 		lexer := lexers.Get("json")
245@@ -45,15 +44,13 @@ 			html.WithLineNumbers(true),
246 		)
247 		iterator, err := lexer.Tokenise(nil, string(b))
248 		if err != nil {
249-			slog.Error("Error tokenise", "error", err)
250-			return
251+			return err
252 		}
253 
254 		var code bytes.Buffer
255 		err = formatter.Format(&code, style, iterator)
256 		if err != nil {
257-			slog.Error("Error format", "error", err)
258-			return
259+			return err
260 		}
261 
262 		hello := &templates.ConfigPage{
263@@ -61,5 +58,6 @@ 			Body: code.Bytes(),
264 		}
265 
266 		templates.WritePageTemplate(w, hello)
267+		return nil
268 	}
269 }
270diff --git a/pkg/handler/git/handler.go b/pkg/handler/git/handler.go
271index 7bdf3729016402aa7f8f1e462d9b34c209109835..d952fef3bc513240d3f602426e7572fa84033a9e 100644
272--- a/pkg/handler/git/handler.go
273+++ b/pkg/handler/git/handler.go
274@@ -3,7 +3,6 @@
275 import (
276 	"bytes"
277 	"io"
278-	"log/slog"
279 	"net/http"
280 	"os"
281 	"path/filepath"
282@@ -50,23 +49,20 @@ 		readmePath: confRepo.GetRootReadme(),
283 	}
284 }
285 
286-func (g *GitHandler) List(w http.ResponseWriter, _ *http.Request) {
287+func (g *GitHandler) List(w http.ResponseWriter, _ *http.Request) error {
288 	repos, err := g.gitService.ListRepositories()
289 	if err != nil {
290-		slog.Error("Error listing repo", "error", err)
291-		return
292+		return err
293 	}
294 
295 	f, err := os.Open(g.readmePath)
296 	if err != nil {
297-		slog.Error("Error loading readme file", "error", err)
298-		return
299+		return err
300 	}
301 
302 	bs, err := io.ReadAll(f)
303 	if err != nil {
304-		slog.Error("Error reading readme file bytes", "error", err)
305-		return
306+		return err
307 	}
308 
309 	extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
310@@ -84,15 +80,15 @@ 		Respositories: repos,
311 		About:         bs,
312 	}
313 	templates.WritePageTemplate(w, gitList)
314+	return nil
315 }
316 
317-func (g *GitHandler) Summary(w http.ResponseWriter, r *http.Request) {
318+func (g *GitHandler) Summary(w http.ResponseWriter, r *http.Request) error {
319 	ext.SetHTML(w)
320 	name := r.PathValue("name")
321 	ref, err := g.gitService.GetHead(name)
322 	if err != nil {
323-		slog.Error("Error loading head", "error", err)
324-		return
325+		return err
326 	}
327 
328 	gitList := &templates.GitItemPage{
329@@ -101,15 +97,15 @@ 		Ref:         ref.Name().Short(),
330 		GitItemBase: &templates.GitItemSummaryPage{},
331 	}
332 	templates.WritePageTemplate(w, gitList)
333+	return nil
334 }
335 
336-func (g *GitHandler) About(w http.ResponseWriter, r *http.Request) {
337+func (g *GitHandler) About(w http.ResponseWriter, r *http.Request) error {
338 	ext.SetHTML(w)
339 	name := r.PathValue("name")
340 	ref, err := g.gitService.GetHead(name)
341 	if err != nil {
342-		slog.Error("Error loading head", "error", err)
343-		return
344+		return err
345 	}
346 	gitList := &templates.GitItemPage{
347 		Name:        name,
348@@ -117,28 +113,26 @@ 		Ref:         ref.Name().Short(),
349 		GitItemBase: &templates.GitItemAboutPage{},
350 	}
351 	templates.WritePageTemplate(w, gitList)
352+	return nil
353 }
354 
355-func (g *GitHandler) Refs(w http.ResponseWriter, r *http.Request) {
356+func (g *GitHandler) Refs(w http.ResponseWriter, r *http.Request) error {
357 	ext.SetHTML(w)
358 	name := r.PathValue("name")
359 
360 	tags, err := g.gitService.ListTags(name)
361 	if err != nil {
362-		slog.Error("Error loading tags", "error", err)
363-		return
364+		return err
365 	}
366 
367 	branches, err := g.gitService.ListBranches(name)
368 	if err != nil {
369-		slog.Error("Error loading branches", "error", err)
370-		return
371+		return err
372 	}
373 
374 	ref, err := g.gitService.GetHead(name)
375 	if err != nil {
376-		slog.Error("Error loading head", "error", err)
377-		return
378+		return err
379 	}
380 
381 	gitList := &templates.GitItemPage{
382@@ -150,9 +144,10 @@ 			Branches: branches,
383 		},
384 	}
385 	templates.WritePageTemplate(w, gitList)
386+	return nil
387 }
388 
389-func (g *GitHandler) Tree(w http.ResponseWriter, r *http.Request) {
390+func (g *GitHandler) Tree(w http.ResponseWriter, r *http.Request) error {
391 	ext.SetHTML(w)
392 	name := r.PathValue("name")
393 	ref := r.PathValue("ref")
394@@ -160,8 +155,7 @@ 	rest := r.PathValue("rest")
395 
396 	tree, err := g.gitService.GetTree(name, ref, rest)
397 	if err != nil {
398-		slog.Error("Error loading tree", "error", err)
399-		return
400+		return err
401 	}
402 
403 	gitList := &templates.GitItemPage{
404@@ -175,9 +169,10 @@ 			Name:        name,
405 		},
406 	}
407 	templates.WritePageTemplate(w, gitList)
408+	return nil
409 }
410 
411-func (g *GitHandler) Blob(w http.ResponseWriter, r *http.Request) {
412+func (g *GitHandler) Blob(w http.ResponseWriter, r *http.Request) error {
413 	ext.SetHTML(w)
414 	name := r.PathValue("name")
415 	ref := r.PathValue("ref")
416@@ -185,8 +180,7 @@ 	rest := r.PathValue("rest")
417 
418 	file, err := g.gitService.GetFileContent(name, ref, rest)
419 	if err != nil {
420-		slog.Error("Error loading blob", "error", err)
421-		return
422+		return err
423 	}
424 
425 	filename := filepath.Base(rest)
426@@ -197,15 +191,13 @@ 		html.WithLineNumbers(true),
427 	)
428 	iterator, err := lexer.Tokenise(nil, file)
429 	if err != nil {
430-		slog.Error("Error tokenise", "error", err)
431-		return
432+		return err
433 	}
434 
435 	var code bytes.Buffer
436 	err = formatter.Format(&code, style, iterator)
437 	if err != nil {
438-		slog.Error("Error format", "error", err)
439-		return
440+		return err
441 	}
442 
443 	gitList := &templates.GitItemPage{
444@@ -217,17 +209,17 @@ 			Content: code.Bytes(),
445 		},
446 	}
447 	templates.WritePageTemplate(w, gitList)
448+	return nil
449 }
450 
451-func (g *GitHandler) Log(w http.ResponseWriter, r *http.Request) {
452+func (g *GitHandler) Log(w http.ResponseWriter, r *http.Request) error {
453 	ext.SetHTML(w)
454 	name := r.PathValue("name")
455 	ref := r.PathValue("ref")
456 
457 	commits, err := g.gitService.ListCommits(name, ref)
458 	if err != nil {
459-		slog.Error("Error loading commits", "error", err)
460-		return
461+		return err
462 	}
463 
464 	gitList := &templates.GitItemPage{
465@@ -238,6 +230,7 @@ 			Commits: commits,
466 		},
467 	}
468 	templates.WritePageTemplate(w, gitList)
469+	return nil
470 }
471 
472 func GetLexers(filename string) chroma.Lexer {
473diff --git a/pkg/handler/router.go b/pkg/handler/router.go
474index bf13ad5f8821c8fa48e6c932afddacd8e2f964f6..3da812feb835f6d0e5ceed41bb688d45f34baae0 100644
475--- a/pkg/handler/router.go
476+++ b/pkg/handler/router.go
477@@ -20,9 +20,9 @@ 	gitService *service.GitService,
478 	configRepo *serverconfig.ConfigurationRepository,
479 ) (http.Handler, error) {
480 	var (
481-		gitHandler   = git.NewGitHandler(gitService, configRepo)
482-		aboutHandler = about.NewAboutHandler(configRepo)
483-		configHander = config.ConfigFile(configRepo)
484+		gitHandler    = git.NewGitHandler(gitService, configRepo)
485+		aboutHandler  = about.NewAboutHandler(configRepo)
486+		configHandler = config.ConfigFile(configRepo)
487 	)
488 
489 	staticHandler, err := static.ServeStaticHandler()
490@@ -30,21 +30,19 @@ 	if err != nil {
491 		return nil, err
492 	}
493 
494-	mux := http.NewServeMux()
495-
496-	mux.HandleFunc("/static/{file}", m(staticHandler))
497-	mux.HandleFunc("/{name}/about/{$}", m(gitHandler.About))
498-	mux.HandleFunc("/{name}", m(gitHandler.Summary))
499-	mux.HandleFunc("/{name}/refs/{$}", m(gitHandler.Refs))
500-	mux.HandleFunc("/{name}/tree/{ref}/{rest...}", m(gitHandler.Tree))
501-	mux.HandleFunc("/{name}/blob/{ref}/{rest...}", m(gitHandler.Blob))
502-	mux.HandleFunc("/{name}/log/{ref}", m(gitHandler.Log))
503-	mux.HandleFunc("/config", m(configHander))
504-	mux.HandleFunc("/about", m(aboutHandler.About))
505-	mux.HandleFunc("/", m(gitHandler.List))
506-	return mux, nil
507-}
508+	mux := ext.NewRouter()
509+	mux.AddMiddleware(ext.Compress)
510+	mux.AddMiddleware(ext.Log)
511 
512-func m(next func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
513-	return ext.Compress(next)
514+	mux.HandleFunc("/static/{file}", staticHandler)
515+	mux.HandleFunc("/{name}/about/{$}", gitHandler.About)
516+	mux.HandleFunc("/{name}", gitHandler.Summary)
517+	mux.HandleFunc("/{name}/refs/{$}", gitHandler.Refs)
518+	mux.HandleFunc("/{name}/tree/{ref}/{rest...}", gitHandler.Tree)
519+	mux.HandleFunc("/{name}/blob/{ref}/{rest...}", gitHandler.Blob)
520+	mux.HandleFunc("/{name}/log/{ref}", gitHandler.Log)
521+	mux.HandleFunc("/config", configHandler)
522+	mux.HandleFunc("/about", aboutHandler.About)
523+	mux.HandleFunc("/", gitHandler.List)
524+	return mux.Handler(), nil
525 }
526diff --git a/pkg/handler/static/handler.go b/pkg/handler/static/handler.go
527index 5155068306666b2303de7f129585108f32ea5dfa..0973d75ec8bf59c89111050112bb0f43368cb147 100644
528--- a/pkg/handler/static/handler.go
529+++ b/pkg/handler/static/handler.go
530@@ -10,19 +10,21 @@ 	"git.gabrielgio.me/cerrado/pkg/ext"
531 	"git.gabrielgio.me/cerrado/static"
532 )
533 
534-func ServeStaticHandler() (func(w http.ResponseWriter, r *http.Request), error) {
535+func ServeStaticHandler() (ext.ErrorRequestHandler, error) {
536 	staticFs, err := fs.Sub(static.Static, ".")
537 	if err != nil {
538 		return nil, err
539 	}
540 
541-	return func(w http.ResponseWriter, r *http.Request) {
542+	return func(w http.ResponseWriter, r *http.Request) error {
543 		var (
544 			f = r.PathValue("file")
545 			e = filepath.Ext(f)
546 			m = mime.TypeByExtension(e)
547 		)
548 		ext.SetMIME(w, m)
549+		w.Header().Add("Cache-Control", "immutable")
550 		http.ServeFileFS(w, r, staticFs, f)
551+		return nil
552 	}, nil
553 }
554diff --git a/pkg/service/git.go b/pkg/service/git.go
555index 31a1cbb47d7c74f552ce0ff097735481613a1aac..94e2adc5ab4433d9cc113f7a83d7c49aafd12787 100644
556--- a/pkg/service/git.go
557+++ b/pkg/service/git.go
558@@ -1,6 +1,7 @@
559 package service
560 
561 import (
562+	"errors"
563 	"log/slog"
564 	"os"
565 	"path"
566@@ -29,6 +30,10 @@ 	configurationRepository interface {
567 		List() []*config.GitRepositoryConfiguration
568 		GetByName(name string) *config.GitRepositoryConfiguration
569 	}
570+)
571+
572+var (
573+	RepositoryNotFoundErr = errors.New("Repository not found")
574 )
575 
576 // TODO: make it configurable
577@@ -84,8 +89,10 @@ 	return repos, nil
578 }
579 
580 func (g *GitService) ListCommits(name, ref string) ([]*object.Commit, error) {
581-	// TODO: handle nil
582 	r := g.configRepo.GetByName(name)
583+	if r == nil {
584+		return nil, RepositoryNotFoundErr
585+	}
586 
587 	repo, err := git.OpenRepository(r.Path)
588 	if err != nil {
589@@ -100,8 +107,10 @@ 	return repo.Commits()
590 }
591 
592 func (g *GitService) GetTree(name, ref, path string) (*object.Tree, error) {
593-	// TODO: handle nil
594 	r := g.configRepo.GetByName(name)
595+	if r == nil {
596+		return nil, RepositoryNotFoundErr
597+	}
598 
599 	repo, err := git.OpenRepository(r.Path)
600 	if err != nil {
601@@ -116,8 +125,10 @@ 	return repo.Tree(path)
602 }
603 
604 func (g *GitService) GetFileContent(name, ref, path string) (string, error) {
605-	// TODO: handle nil
606 	r := g.configRepo.GetByName(name)
607+	if r == nil {
608+		return "", RepositoryNotFoundErr
609+	}
610 
611 	repo, err := git.OpenRepository(r.Path)
612 	if err != nil {
613@@ -132,8 +143,10 @@ 	return repo.FileContent(path)
614 }
615 
616 func (g *GitService) ListTags(name string) ([]*object.Tag, error) {
617-	// TODO: handle nil
618 	r := g.configRepo.GetByName(name)
619+	if r == nil {
620+		return nil, RepositoryNotFoundErr
621+	}
622 
623 	repo, err := git.OpenRepository(r.Path)
624 	if err != nil {
625@@ -143,8 +156,10 @@ 	return repo.Tags()
626 }
627 
628 func (g *GitService) ListBranches(name string) ([]*plumbing.Reference, error) {
629-	// TODO: handle nil
630 	r := g.configRepo.GetByName(name)
631+	if r == nil {
632+		return nil, RepositoryNotFoundErr
633+	}
634 
635 	repo, err := git.OpenRepository(r.Path)
636 	if err != nil {
637@@ -154,8 +169,10 @@ 	return repo.Branches()
638 }
639 
640 func (g *GitService) GetHead(name string) (*plumbing.Reference, error) {
641-	// TODO: handle nil
642 	r := g.configRepo.GetByName(name)
643+	if r == nil {
644+		return nil, RepositoryNotFoundErr
645+	}
646 
647 	repo, err := git.OpenRepository(r.Path)
648 	if err != nil {
649diff --git a/templates/error.qtpl b/templates/error.qtpl
650new file mode 100644
651index 0000000000000000000000000000000000000000..771d5333f95ee509b2e1680de168003bf24b2b32
652--- /dev/null
653+++ b/templates/error.qtpl
654@@ -0,0 +1,16 @@
655+{% code
656+type ErrorPage struct {
657+    Message string
658+}
659+%}
660+
661+{% func (p *ErrorPage) Title() %}Error{% endfunc %}
662+
663+{% func (p *ErrorPage) Navbar() %}{%= Navbar(Git) %}{% endfunc %}
664+
665+{% func (p *ErrorPage) Content() %}
666+{%s p.Message %}
667+{% endfunc %}
668+
669+{% func (p *ErrorPage) Script() %}
670+{% endfunc %}
671diff --git a/templates/error.qtpl.go b/templates/error.qtpl.go
672new file mode 100644
673index 0000000000000000000000000000000000000000..099395f076a3fea1744d060bf5d96617e3c3bc21
674--- /dev/null
675+++ b/templates/error.qtpl.go
676@@ -0,0 +1,162 @@
677+// Code generated by qtc from "error.qtpl". DO NOT EDIT.
678+// See https://github.com/valyala/quicktemplate for details.
679+
680+//line error.qtpl:1
681+package templates
682+
683+//line error.qtpl:1
684+import (
685+	qtio422016 "io"
686+
687+	qt422016 "github.com/valyala/quicktemplate"
688+)
689+
690+//line error.qtpl:1
691+var (
692+	_ = qtio422016.Copy
693+	_ = qt422016.AcquireByteBuffer
694+)
695+
696+//line error.qtpl:2
697+type ErrorPage struct {
698+	Message string
699+}
700+
701+//line error.qtpl:7
702+func (p *ErrorPage) StreamTitle(qw422016 *qt422016.Writer) {
703+//line error.qtpl:7
704+	qw422016.N().S(`Error`)
705+//line error.qtpl:7
706+}
707+
708+//line error.qtpl:7
709+func (p *ErrorPage) WriteTitle(qq422016 qtio422016.Writer) {
710+//line error.qtpl:7
711+	qw422016 := qt422016.AcquireWriter(qq422016)
712+//line error.qtpl:7
713+	p.StreamTitle(qw422016)
714+//line error.qtpl:7
715+	qt422016.ReleaseWriter(qw422016)
716+//line error.qtpl:7
717+}
718+
719+//line error.qtpl:7
720+func (p *ErrorPage) Title() string {
721+//line error.qtpl:7
722+	qb422016 := qt422016.AcquireByteBuffer()
723+//line error.qtpl:7
724+	p.WriteTitle(qb422016)
725+//line error.qtpl:7
726+	qs422016 := string(qb422016.B)
727+//line error.qtpl:7
728+	qt422016.ReleaseByteBuffer(qb422016)
729+//line error.qtpl:7
730+	return qs422016
731+//line error.qtpl:7
732+}
733+
734+//line error.qtpl:9
735+func (p *ErrorPage) StreamNavbar(qw422016 *qt422016.Writer) {
736+//line error.qtpl:9
737+	StreamNavbar(qw422016, Git)
738+//line error.qtpl:9
739+}
740+
741+//line error.qtpl:9
742+func (p *ErrorPage) WriteNavbar(qq422016 qtio422016.Writer) {
743+//line error.qtpl:9
744+	qw422016 := qt422016.AcquireWriter(qq422016)
745+//line error.qtpl:9
746+	p.StreamNavbar(qw422016)
747+//line error.qtpl:9
748+	qt422016.ReleaseWriter(qw422016)
749+//line error.qtpl:9
750+}
751+
752+//line error.qtpl:9
753+func (p *ErrorPage) Navbar() string {
754+//line error.qtpl:9
755+	qb422016 := qt422016.AcquireByteBuffer()
756+//line error.qtpl:9
757+	p.WriteNavbar(qb422016)
758+//line error.qtpl:9
759+	qs422016 := string(qb422016.B)
760+//line error.qtpl:9
761+	qt422016.ReleaseByteBuffer(qb422016)
762+//line error.qtpl:9
763+	return qs422016
764+//line error.qtpl:9
765+}
766+
767+//line error.qtpl:11
768+func (p *ErrorPage) StreamContent(qw422016 *qt422016.Writer) {
769+//line error.qtpl:11
770+	qw422016.N().S(`
771+`)
772+//line error.qtpl:12
773+	qw422016.E().S(p.Message)
774+//line error.qtpl:12
775+	qw422016.N().S(`
776+`)
777+//line error.qtpl:13
778+}
779+
780+//line error.qtpl:13
781+func (p *ErrorPage) WriteContent(qq422016 qtio422016.Writer) {
782+//line error.qtpl:13
783+	qw422016 := qt422016.AcquireWriter(qq422016)
784+//line error.qtpl:13
785+	p.StreamContent(qw422016)
786+//line error.qtpl:13
787+	qt422016.ReleaseWriter(qw422016)
788+//line error.qtpl:13
789+}
790+
791+//line error.qtpl:13
792+func (p *ErrorPage) Content() string {
793+//line error.qtpl:13
794+	qb422016 := qt422016.AcquireByteBuffer()
795+//line error.qtpl:13
796+	p.WriteContent(qb422016)
797+//line error.qtpl:13
798+	qs422016 := string(qb422016.B)
799+//line error.qtpl:13
800+	qt422016.ReleaseByteBuffer(qb422016)
801+//line error.qtpl:13
802+	return qs422016
803+//line error.qtpl:13
804+}
805+
806+//line error.qtpl:15
807+func (p *ErrorPage) StreamScript(qw422016 *qt422016.Writer) {
808+//line error.qtpl:15
809+	qw422016.N().S(`
810+`)
811+//line error.qtpl:16
812+}
813+
814+//line error.qtpl:16
815+func (p *ErrorPage) WriteScript(qq422016 qtio422016.Writer) {
816+//line error.qtpl:16
817+	qw422016 := qt422016.AcquireWriter(qq422016)
818+//line error.qtpl:16
819+	p.StreamScript(qw422016)
820+//line error.qtpl:16
821+	qt422016.ReleaseWriter(qw422016)
822+//line error.qtpl:16
823+}
824+
825+//line error.qtpl:16
826+func (p *ErrorPage) Script() string {
827+//line error.qtpl:16
828+	qb422016 := qt422016.AcquireByteBuffer()
829+//line error.qtpl:16
830+	p.WriteScript(qb422016)
831+//line error.qtpl:16
832+	qs422016 := string(qb422016.B)
833+//line error.qtpl:16
834+	qt422016.ReleaseByteBuffer(qb422016)
835+//line error.qtpl:16
836+	return qs422016
837+//line error.qtpl:16
838+}
839diff --git a/templates/gititem.qtpl b/templates/gititem.qtpl
840index 3e2dd4e543265ca86fbe8832a7001b392edb0b7b..d6957820865ed335415da36d5fc0d240039d0350 100644
841--- a/templates/gititem.qtpl
842+++ b/templates/gititem.qtpl
843@@ -13,7 +13,7 @@     GitItemBase
844 }
845 %}
846 
847-{% func (p *GitItemPage) Title() %}Git | List{% endfunc %}
848+{% func (p *GitItemPage) Title() %}Git | {%s p.Name %}{% endfunc %}
849 
850 {% func (p *GitItemPage) Navbar() %}{%= Navbar(Git) %}{% endfunc %}
851 
852diff --git a/templates/gititem.qtpl.go b/templates/gititem.qtpl.go
853index 2c4610465d702fd2ceab108977a1dff00576ab68..a7ed65941e2355817c95d8429afe3a34d0a91fe9 100644
854--- a/templates/gititem.qtpl.go
855+++ b/templates/gititem.qtpl.go
856@@ -44,7 +44,9 @@
857 //line gititem.qtpl:16
858 func (p *GitItemPage) StreamTitle(qw422016 *qt422016.Writer) {
859 //line gititem.qtpl:16
860-	qw422016.N().S(`Git | List`)
861+	qw422016.N().S(`Git | `)
862+//line gititem.qtpl:16
863+	qw422016.E().S(p.Name)
864 //line gititem.qtpl:16
865 }
866