arche / internal/ui/server.go

commit 154431fd
  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}