1package ui
2
3import (
4 "embed"
5 "encoding/hex"
6 "fmt"
7 "html/template"
8 "net/http"
9 "strings"
10
11 "arche/internal/diff"
12 "arche/internal/issuedb"
13 "arche/internal/markdown"
14 "arche/internal/object"
15 "arche/internal/repo"
16 "arche/internal/wc"
17)
18
19//go:embed templates/*.html
20var tmplFS embed.FS
21
22type server struct {
23 r *repo.Repo
24 idb *issuedb.DB
25}
26
27func Serve(r *repo.Repo, port int) error {
28 idb, err := issuedb.Open(r.ArcheDir())
29 if err != nil {
30 return fmt.Errorf("issuedb: %w", err)
31 }
32 defer idb.Close()
33
34 s := &server{r: r, idb: idb}
35 mux := http.NewServeMux()
36 mux.HandleFunc("/", s.handleRoot)
37 mux.HandleFunc("/log", s.handleLog)
38 mux.HandleFunc("/commit", s.handleCommit)
39 mux.HandleFunc("/tree", s.handleTree)
40 mux.HandleFunc("/status", s.handleStatus)
41 mux.HandleFunc("/bookmarks", s.handleBookmarks)
42 mux.HandleFunc("/issues", s.handleIssues)
43 mux.HandleFunc("/issue", s.handleIssue)
44 mux.HandleFunc("/wiki", s.handleWiki)
45 mux.HandleFunc("/wiki/page", s.handleWikiPage)
46
47 addr := fmt.Sprintf("localhost:%d", port)
48 fmt.Printf("arche ui: listening on http://%s\n", addr)
49 return http.ListenAndServe(addr, mux)
50}
51
52var tmplFuncs = template.FuncMap{
53 "markdown": markdown.Render,
54}
55
56func parsePage(name string) (*template.Template, error) {
57 return template.New("").Funcs(tmplFuncs).ParseFS(tmplFS, "templates/base.html", "templates/"+name)
58}
59
60func render(w http.ResponseWriter, page string, data any) {
61 t, err := parsePage(page)
62 if err != nil {
63 http.Error(w, "template parse: "+err.Error(), http.StatusInternalServerError)
64 return
65 }
66 w.Header().Set("Content-Type", "text/html; charset=utf-8")
67 if err := t.ExecuteTemplate(w, page, data); err != nil {
68 fmt.Printf("arche ui: template execute %s: %v\n", page, err)
69 }
70}
71
72func shortHex(id [32]byte) string { return hex.EncodeToString(id[:])[:8] }
73func fullHex(id [32]byte) string { return hex.EncodeToString(id[:]) }
74func phaseClass(p object.Phase) string { return strings.ToLower(p.String()) }
75
76func bookmarkMap(r *repo.Repo) map[string][]string {
77 bms, _ := r.Store.ListBookmarks()
78 m := make(map[string][]string, len(bms))
79 for _, b := range bms {
80 k := fullHex(b.CommitID)
81 m[k] = append(m[k], b.Name)
82 }
83 return m
84}
85
86type logData struct{ Commits []commitRowData }
87
88type commitRowData struct {
89 HexID string
90 ShortHex string
91 ChangeID string
92 Author string
93 Date string
94 Phase string
95 PhaseClass string
96 Message string
97 Bookmarks []string
98 IsHead bool
99}
100
101func (s *server) handleRoot(w http.ResponseWriter, r *http.Request) {
102 if r.URL.Path == "/" {
103 http.Redirect(w, r, "/log", http.StatusFound)
104 return
105 }
106 http.NotFound(w, r)
107}
108
109func (s *server) handleLog(w http.ResponseWriter, req *http.Request) {
110 const maxCommits = 200
111 _, headID, err := s.r.HeadCommit()
112 if err != nil {
113 http.Error(w, "HEAD: "+err.Error(), http.StatusInternalServerError)
114 return
115 }
116
117 headCID, _ := s.r.HeadChangeID()
118 bmMap := bookmarkMap(s.r)
119
120 visited := map[[32]byte]bool{}
121 queue := [][32]byte{headID}
122
123 var rows []commitRowData
124 for len(queue) > 0 && len(rows) < maxCommits {
125 id := queue[0]
126 queue = queue[1:]
127
128 if visited[id] {
129 continue
130 }
131 visited[id] = true
132
133 c, err := s.r.ReadCommit(id)
134 if err != nil {
135 continue
136 }
137
138 phase, _ := s.r.Store.GetPhase(id)
139 hexID := fullHex(id)
140
141 msg := c.Message
142 if idx := strings.IndexByte(msg, '\n'); idx >= 0 {
143 msg = msg[:idx]
144 }
145
146 rows = append(rows, commitRowData{
147 HexID: hexID,
148 ShortHex: shortHex(id),
149 ChangeID: c.ChangeID,
150 Author: c.Author.Name,
151 Date: c.Author.Timestamp.Format("2006-01-02 15:04"),
152 Phase: phase.String(),
153 PhaseClass: phaseClass(phase),
154 Message: msg,
155 Bookmarks: bmMap[hexID],
156 IsHead: c.ChangeID == headCID,
157 })
158
159 for _, p := range c.Parents {
160 if !visited[p] {
161 queue = append(queue, p)
162 }
163 }
164 }
165
166 render(w, "log.html", logData{Commits: rows})
167}
168
169type parentLink struct {
170 HexID string
171 ShortHex string
172}
173
174type diffLine struct {
175 Class string
176 Text string
177}
178
179type fileDiffRender struct {
180 Path string
181 Status string
182 Lines []diffLine
183}
184
185type commitData struct {
186 HexID string
187 ShortHex string
188 ChangeID string
189 Author string
190 Committer string
191 Date string
192 Phase string
193 PhaseClass string
194 Message string
195 Bookmarks []string
196 Parents []parentLink
197 Diffs []fileDiffRender
198}
199
200func parseDiffLines(patch string) []diffLine {
201 if patch == "" {
202 return nil
203 }
204 var out []diffLine
205 for _, line := range strings.Split(patch, "\n") {
206 var class string
207 switch {
208 case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"),
209 strings.HasPrefix(line, "diff "), strings.HasPrefix(line, "@@"):
210 class = "diff-hdr"
211 case strings.HasPrefix(line, "+"):
212 class = "diff-add"
213 case strings.HasPrefix(line, "-"):
214 class = "diff-del"
215 }
216 out = append(out, diffLine{Class: class, Text: line})
217 }
218 return out
219}
220
221func (s *server) handleCommit(w http.ResponseWriter, req *http.Request) {
222 idStr := req.URL.Query().Get("id")
223 if idStr == "" {
224 http.Error(w, "missing id parameter", http.StatusBadRequest)
225 return
226 }
227
228 raw, err := hex.DecodeString(idStr)
229 if err != nil || len(raw) != 32 {
230 http.Error(w, "invalid commit id", http.StatusBadRequest)
231 return
232 }
233
234 var id [32]byte
235 copy(id[:], raw)
236
237 c, err := s.r.ReadCommit(id)
238 if err != nil {
239 http.Error(w, "not found: "+err.Error(), http.StatusNotFound)
240 return
241 }
242
243 phase, _ := s.r.Store.GetPhase(id)
244 bmMap := bookmarkMap(s.r)
245
246 var parents []parentLink
247 for _, p := range c.Parents {
248 parents = append(parents, parentLink{
249 HexID: fullHex(p),
250 ShortHex: shortHex(p),
251 })
252 }
253
254 diffs, _ := diff.CommitDiff(s.r, id)
255 var rendered []fileDiffRender
256 for _, fd := range diffs {
257 rendered = append(rendered, fileDiffRender{
258 Path: fd.Path,
259 Status: string(fd.Status),
260 Lines: parseDiffLines(fd.Patch),
261 })
262 }
263
264 hexID := fullHex(id)
265
266 render(w, "commit.html", commitData{
267 HexID: hexID,
268 ShortHex: shortHex(id),
269 ChangeID: c.ChangeID,
270 Author: fmt.Sprintf("%s <%s>", c.Author.Name, c.Author.Email),
271 Committer: fmt.Sprintf("%s <%s>", c.Committer.Name, c.Committer.Email),
272 Date: c.Author.Timestamp.Format("2006-01-02 15:04:05"),
273 Phase: phase.String(),
274 PhaseClass: phaseClass(phase),
275 Message: c.Message,
276 Bookmarks: bmMap[hexID],
277 Parents: parents,
278 Diffs: rendered,
279 })
280}
281
282type pathPart struct {
283 Name string
284 Link string
285}
286
287type treeEntry struct {
288 Name string
289 IsDir bool
290 Mode string
291 Link string
292}
293
294type treeData struct {
295 CommitHex string
296 ShortHex string
297 TreePath string
298 PathParts []pathPart
299 Entries []treeEntry
300}
301
302func modeStr(m object.EntryMode) string {
303 switch m {
304 case object.ModeExec:
305 return "exec"
306 case object.ModeSymlink:
307 return "link"
308 default:
309 return "file"
310 }
311}
312
313func (s *server) handleTree(w http.ResponseWriter, req *http.Request) {
314 idStr := req.URL.Query().Get("id")
315 treePath := req.URL.Query().Get("path")
316
317 var commitID [32]byte
318 if idStr != "" {
319 raw, err := hex.DecodeString(idStr)
320 if err != nil || len(raw) != 32 {
321 http.Error(w, "invalid commit id", http.StatusBadRequest)
322 return
323 }
324 copy(commitID[:], raw)
325 } else {
326 _, id, err := s.r.HeadCommit()
327 if err != nil {
328 http.Error(w, "HEAD: "+err.Error(), http.StatusInternalServerError)
329 return
330 }
331 commitID = id
332 }
333
334 c, err := s.r.ReadCommit(commitID)
335 if err != nil {
336 http.Error(w, "commit not found: "+err.Error(), http.StatusNotFound)
337 return
338 }
339
340 tree, err := s.r.ReadTree(c.TreeID)
341 if err != nil {
342 http.Error(w, "tree not found: "+err.Error(), http.StatusInternalServerError)
343 return
344 }
345
346 treePath = strings.Trim(treePath, "/")
347 if treePath != "" {
348 for _, part := range strings.Split(treePath, "/") {
349 var found *object.TreeEntry
350 for i := range tree.Entries {
351 if tree.Entries[i].Name == part {
352 found = &tree.Entries[i]
353 break
354 }
355 }
356 if found == nil {
357 http.Error(w, "path not found", http.StatusNotFound)
358 return
359 }
360 if found.Mode != object.ModeDir {
361 http.Error(w, "not a directory", http.StatusBadRequest)
362 return
363 }
364 tree, err = s.r.ReadTree(found.ObjectID)
365 if err != nil {
366 http.Error(w, "subtree not found: "+err.Error(), http.StatusInternalServerError)
367 return
368 }
369 }
370 }
371
372 commitHex := fullHex(commitID)
373
374 var parts []pathPart
375 if treePath != "" {
376 acc := ""
377 for _, seg := range strings.Split(treePath, "/") {
378 if acc != "" {
379 acc += "/"
380 }
381 acc += seg
382 parts = append(parts, pathPart{
383 Name: seg,
384 Link: fmt.Sprintf("/tree?id=%s&path=%s", commitHex, acc),
385 })
386 }
387 }
388
389 var entries []treeEntry
390 for _, e := range tree.Entries {
391 isDir := e.Mode == object.ModeDir
392 var link string
393 childPath := e.Name
394 if treePath != "" {
395 childPath = treePath + "/" + e.Name
396 }
397 if isDir {
398 link = fmt.Sprintf("/tree?id=%s&path=%s", commitHex, childPath)
399 } else {
400 link = fmt.Sprintf("/tree?id=%s&path=%s", commitHex, childPath)
401 }
402 entries = append(entries, treeEntry{
403 Name: e.Name,
404 IsDir: isDir,
405 Mode: modeStr(e.Mode),
406 Link: link,
407 })
408 }
409
410 render(w, "tree.html", treeData{
411 CommitHex: commitHex,
412 ShortHex: shortHex(commitID),
413 TreePath: treePath,
414 PathParts: parts,
415 Entries: entries,
416 })
417}
418
419type statusRowData struct {
420 StatusChar string
421 StatusClass string
422 Path string
423}
424
425type statusData struct {
426 ChangeID string
427 HexID string
428 ShortHex string
429 Phase string
430 PhaseClass string
431 Changes []statusRowData
432}
433
434func (s *server) handleStatus(w http.ResponseWriter, req *http.Request) {
435 c, id, err := s.r.HeadCommit()
436 if err != nil {
437 http.Error(w, "HEAD: "+err.Error(), http.StatusInternalServerError)
438 return
439 }
440
441 phase, _ := s.r.Store.GetPhase(id)
442
443 changes, _ := wc.New(s.r).Status()
444 var rows []statusRowData
445 for _, ch := range changes {
446 sc := string(ch.Status)
447 rows = append(rows, statusRowData{
448 StatusChar: sc,
449 StatusClass: sc,
450 Path: ch.Path,
451 })
452 }
453
454 render(w, "status.html", statusData{
455 ChangeID: c.ChangeID,
456 HexID: fullHex(id),
457 ShortHex: shortHex(id),
458 Phase: phase.String(),
459 PhaseClass: phaseClass(phase),
460 Changes: rows,
461 })
462}
463
464type bookmarkRowData struct {
465 Name string
466 HexID string
467 ShortHex string
468 ChangeID string
469 Phase string
470 PhaseClass string
471 IsHead bool
472}
473
474type bookmarksData struct {
475 Bookmarks []bookmarkRowData
476}
477
478func (s *server) handleBookmarks(w http.ResponseWriter, req *http.Request) {
479 headCID, _ := s.r.HeadChangeID()
480 bms, _ := s.r.Store.ListBookmarks()
481 var rows []bookmarkRowData
482 for _, b := range bms {
483 c, err := s.r.ReadCommit(b.CommitID)
484 if err != nil {
485 continue
486 }
487 phase, _ := s.r.Store.GetPhase(b.CommitID)
488 rows = append(rows, bookmarkRowData{
489 Name: b.Name,
490 HexID: fullHex(b.CommitID),
491 ShortHex: shortHex(b.CommitID),
492 ChangeID: c.ChangeID,
493 Phase: phase.String(),
494 PhaseClass: phaseClass(phase),
495 IsHead: c.ChangeID == headCID,
496 })
497 }
498
499 render(w, "bookmarks.html", bookmarksData{Bookmarks: rows})
500}
501
502type issueRowData struct {
503 ID string
504 Status string
505 Title string
506}
507
508type issuesData struct {
509 Issues []issueRowData
510}
511
512func (s *server) handleIssues(w http.ResponseWriter, req *http.Request) {
513 stubs, err := s.idb.Issues.ListIssues()
514 if err != nil {
515 http.Error(w, err.Error(), http.StatusInternalServerError)
516 return
517 }
518 var rows []issueRowData
519 for _, st := range stubs {
520 rows = append(rows, issueRowData{ID: st.ID, Status: st.Status, Title: st.Title})
521 }
522 render(w, "issues.html", issuesData{Issues: rows})
523}
524
525func (s *server) handleIssue(w http.ResponseWriter, req *http.Request) {
526 id := req.URL.Query().Get("id")
527 if id == "" {
528 http.Redirect(w, req, "/issues", http.StatusFound)
529 return
530 }
531 iss, err := s.idb.Issues.GetIssue(id)
532 if err != nil {
533 http.Error(w, err.Error(), http.StatusNotFound)
534 return
535 }
536 render(w, "issue.html", iss)
537}
538
539type wikiListData struct {
540 Pages interface{}
541}
542
543func (s *server) handleWiki(w http.ResponseWriter, req *http.Request) {
544 pages, err := s.idb.Wiki.List()
545 if err != nil {
546 http.Error(w, err.Error(), http.StatusInternalServerError)
547 return
548 }
549 render(w, "wiki.html", wikiListData{Pages: pages})
550}
551
552func (s *server) handleWikiPage(w http.ResponseWriter, req *http.Request) {
553 title := req.URL.Query().Get("title")
554 if title == "" {
555 http.Redirect(w, req, "/wiki", http.StatusFound)
556 return
557 }
558 page, err := s.idb.Wiki.Get(title)
559 if err != nil {
560 http.Error(w, err.Error(), http.StatusNotFound)
561 return
562 }
563 render(w, "wikipage.html", page)
564}