forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

Changed files
+4889 -2202
appview
cmd
combinediff
interdiff
docker
docs
knotserver
patchutil
rbac
types
+35 -20
appview/db/issues.go
··· 5 5 "time" 6 6 7 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.sh/tangled.sh/core/appview/pagination" 8 9 ) 9 10 10 11 type Issue struct { ··· 102 103 return ownerDid, err 103 104 } 104 105 105 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool) ([]Issue, error) { 106 + func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 106 107 var issues []Issue 107 108 openValue := 0 108 109 if isOpen { ··· 110 111 } 111 112 112 113 rows, err := e.Query( 113 - `select 114 - i.owner_did, 115 - i.issue_id, 116 - i.created, 117 - i.title, 118 - i.body, 119 - i.open, 120 - count(c.id) 121 - from 122 - issues i 123 - left join 124 - comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 125 - where 126 - i.repo_at = ? and i.open = ? 127 - group by 128 - i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 129 - order by 130 - i.created desc`, 131 - repoAt, openValue) 114 + ` 115 + with numbered_issue as ( 116 + select 117 + i.owner_did, 118 + i.issue_id, 119 + i.created, 120 + i.title, 121 + i.body, 122 + i.open, 123 + count(c.id) as comment_count, 124 + row_number() over (order by i.created desc) as row_num 125 + from 126 + issues i 127 + left join 128 + comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 129 + where 130 + i.repo_at = ? and i.open = ? 131 + group by 132 + i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 133 + ) 134 + select 135 + owner_did, 136 + issue_id, 137 + created, 138 + title, 139 + body, 140 + open, 141 + comment_count 142 + from 143 + numbered_issue 144 + where 145 + row_num between ? and ?`, 146 + repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 132 147 if err != nil { 133 148 return nil, err 134 149 }
+161 -12
appview/db/pulls.go
··· 4 4 "database/sql" 5 5 "fmt" 6 6 "log" 7 + "sort" 7 8 "strings" 8 9 "time" 9 10 10 11 "github.com/bluekeyes/go-gitdiff/gitdiff" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.sh/tangled.sh/core/patchutil" 12 14 "tangled.sh/tangled.sh/core/types" 13 15 ) 14 16 ··· 122 124 return len(p.Submissions) - 1 123 125 } 124 126 125 - func (p *Pull) IsSameRepoBranch() bool { 127 + func (p *Pull) IsPatchBased() bool { 128 + return p.PullSource == nil 129 + } 130 + 131 + func (p *Pull) IsBranchBased() bool { 126 132 if p.PullSource != nil { 127 133 if p.PullSource.RepoAt != nil { 128 134 return p.PullSource.RepoAt == &p.RepoAt ··· 134 140 return false 135 141 } 136 142 137 - func (p *Pull) IsPatch() bool { 138 - return p.PullSource == nil 143 + func (p *Pull) IsForkBased() bool { 144 + if p.PullSource != nil { 145 + if p.PullSource.RepoAt != nil { 146 + // make sure repos are different 147 + return p.PullSource.RepoAt != &p.RepoAt 148 + } 149 + } 150 + return false 139 151 } 140 152 141 - func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff { 153 + func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) { 142 154 patch := s.Patch 143 155 144 - diffs, _, err := gitdiff.Parse(strings.NewReader(patch)) 156 + // if format-patch; then extract each patch 157 + var diffs []*gitdiff.File 158 + if patchutil.IsFormatPatch(patch) { 159 + patches, err := patchutil.ExtractPatches(patch) 160 + if err != nil { 161 + return nil, err 162 + } 163 + var ps [][]*gitdiff.File 164 + for _, p := range patches { 165 + ps = append(ps, p.Files) 166 + } 167 + 168 + diffs = patchutil.CombineDiff(ps...) 169 + } else { 170 + d, _, err := gitdiff.Parse(strings.NewReader(patch)) 171 + if err != nil { 172 + return nil, err 173 + } 174 + diffs = d 175 + } 176 + 177 + return diffs, nil 178 + } 179 + 180 + func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff { 181 + diffs, err := s.AsDiff(targetBranch) 145 182 if err != nil { 146 183 log.Println(err) 147 184 } ··· 177 214 nd.Stat.FilesChanged = len(diffs) 178 215 179 216 return nd 217 + } 218 + 219 + func (s PullSubmission) IsFormatPatch() bool { 220 + return patchutil.IsFormatPatch(s.Patch) 221 + } 222 + 223 + func (s PullSubmission) AsFormatPatch() []patchutil.FormatPatch { 224 + patches, err := patchutil.ExtractPatches(s.Patch) 225 + if err != nil { 226 + log.Println("error extracting patches from submission:", err) 227 + return []patchutil.FormatPatch{} 228 + } 229 + 230 + return patches 180 231 } 181 232 182 233 func NewPull(tx *sql.Tx, pull *Pull) error { ··· 264 315 return pullId - 1, err 265 316 } 266 317 267 - func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]Pull, error) { 268 - var pulls []Pull 318 + func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { 319 + pulls := make(map[int]*Pull) 269 320 270 321 rows, err := e.Query(` 271 322 select ··· 283 334 from 284 335 pulls 285 336 where 286 - repo_at = ? and state = ? 287 - order by 288 - created desc`, repoAt, state) 337 + repo_at = ? and state = ?`, repoAt, state) 289 338 if err != nil { 290 339 return nil, err 291 340 } ··· 331 380 } 332 381 } 333 382 334 - pulls = append(pulls, pull) 383 + pulls[pull.PullId] = &pull 384 + } 385 + 386 + // get latest round no. for each pull 387 + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 388 + submissionsQuery := fmt.Sprintf(` 389 + select 390 + id, pull_id, round_number 391 + from 392 + pull_submissions 393 + where 394 + repo_at = ? and pull_id in (%s) 395 + `, inClause) 396 + 397 + args := make([]any, len(pulls)+1) 398 + args[0] = repoAt.String() 399 + idx := 1 400 + for _, p := range pulls { 401 + args[idx] = p.PullId 402 + idx += 1 335 403 } 404 + submissionsRows, err := e.Query(submissionsQuery, args...) 405 + if err != nil { 406 + return nil, err 407 + } 408 + defer submissionsRows.Close() 409 + 410 + for submissionsRows.Next() { 411 + var s PullSubmission 412 + err := submissionsRows.Scan( 413 + &s.ID, 414 + &s.PullId, 415 + &s.RoundNumber, 416 + ) 417 + if err != nil { 418 + return nil, err 419 + } 336 420 421 + if p, ok := pulls[s.PullId]; ok { 422 + p.Submissions = make([]*PullSubmission, s.RoundNumber+1) 423 + p.Submissions[s.RoundNumber] = &s 424 + } 425 + } 337 426 if err := rows.Err(); err != nil { 338 427 return nil, err 339 428 } 340 429 341 - return pulls, nil 430 + // get comment count on latest submission on each pull 431 + inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 432 + commentsQuery := fmt.Sprintf(` 433 + select 434 + count(id), pull_id 435 + from 436 + pull_comments 437 + where 438 + submission_id in (%s) 439 + group by 440 + submission_id 441 + `, inClause) 442 + 443 + args = []any{} 444 + for _, p := range pulls { 445 + args = append(args, p.Submissions[p.LastRoundNumber()].ID) 446 + } 447 + commentsRows, err := e.Query(commentsQuery, args...) 448 + if err != nil { 449 + return nil, err 450 + } 451 + defer commentsRows.Close() 452 + 453 + for commentsRows.Next() { 454 + var commentCount, pullId int 455 + err := commentsRows.Scan( 456 + &commentCount, 457 + &pullId, 458 + ) 459 + if err != nil { 460 + return nil, err 461 + } 462 + if p, ok := pulls[pullId]; ok { 463 + p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount) 464 + } 465 + } 466 + if err := rows.Err(); err != nil { 467 + return nil, err 468 + } 469 + 470 + orderedByDate := []*Pull{} 471 + for _, p := range pulls { 472 + orderedByDate = append(orderedByDate, p) 473 + } 474 + sort.Slice(orderedByDate, func(i, j int) bool { 475 + return orderedByDate[i].Created.After(orderedByDate[j].Created) 476 + }) 477 + 478 + return orderedByDate, nil 342 479 } 343 480 344 481 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { ··· 515 652 } 516 653 if err = commentsRows.Err(); err != nil { 517 654 return nil, err 655 + } 656 + 657 + var pullSourceRepo *Repo 658 + if pull.PullSource != nil { 659 + if pull.PullSource.RepoAt != nil { 660 + pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 661 + if err != nil { 662 + log.Printf("failed to get repo by at uri: %v", err) 663 + } else { 664 + pull.PullSource.Repo = pullSourceRepo 665 + } 666 + } 518 667 } 519 668 520 669 pull.Submissions = make([]*PullSubmission, len(submissionsMap))
+62
appview/filetree/filetree.go
··· 1 + package filetree 2 + 3 + import ( 4 + "path/filepath" 5 + "sort" 6 + "strings" 7 + ) 8 + 9 + type FileTreeNode struct { 10 + Name string 11 + Path string 12 + IsDirectory bool 13 + Children map[string]*FileTreeNode 14 + } 15 + 16 + // NewNode creates a new node 17 + func newNode(name, path string, isDir bool) *FileTreeNode { 18 + return &FileTreeNode{ 19 + Name: name, 20 + Path: path, 21 + IsDirectory: isDir, 22 + Children: make(map[string]*FileTreeNode), 23 + } 24 + } 25 + 26 + func FileTree(files []string) *FileTreeNode { 27 + rootNode := newNode("", "", true) 28 + 29 + sort.Strings(files) 30 + 31 + for _, file := range files { 32 + if file == "" { 33 + continue 34 + } 35 + 36 + parts := strings.Split(filepath.Clean(file), "/") 37 + if len(parts) == 0 { 38 + continue 39 + } 40 + 41 + currentNode := rootNode 42 + currentPath := "" 43 + 44 + for i, part := range parts { 45 + if currentPath == "" { 46 + currentPath = part 47 + } else { 48 + currentPath = filepath.Join(currentPath, part) 49 + } 50 + 51 + isDir := i < len(parts)-1 52 + 53 + if _, exists := currentNode.Children[part]; !exists { 54 + currentNode.Children[part] = newNode(part, currentPath, isDir) 55 + } 56 + 57 + currentNode = currentNode.Children[part] 58 + } 59 + } 60 + 61 + return rootNode 62 + }
+126
appview/middleware/middleware.go
··· 1 + package middleware 2 + 3 + import ( 4 + "context" 5 + "log" 6 + "net/http" 7 + "strconv" 8 + "time" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + "tangled.sh/tangled.sh/core/appview" 13 + "tangled.sh/tangled.sh/core/appview/auth" 14 + "tangled.sh/tangled.sh/core/appview/pagination" 15 + ) 16 + 17 + type Middleware func(http.Handler) http.Handler 18 + 19 + func AuthMiddleware(a *auth.Auth) Middleware { 20 + return func(next http.Handler) http.Handler { 21 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 + redirectFunc := func(w http.ResponseWriter, r *http.Request) { 23 + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 24 + } 25 + if r.Header.Get("HX-Request") == "true" { 26 + redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 27 + w.Header().Set("HX-Redirect", "/login") 28 + w.WriteHeader(http.StatusOK) 29 + } 30 + } 31 + 32 + session, err := a.GetSession(r) 33 + if session.IsNew || err != nil { 34 + log.Printf("not logged in, redirecting") 35 + redirectFunc(w, r) 36 + return 37 + } 38 + 39 + authorized, ok := session.Values[appview.SessionAuthenticated].(bool) 40 + if !ok || !authorized { 41 + log.Printf("not logged in, redirecting") 42 + redirectFunc(w, r) 43 + return 44 + } 45 + 46 + // refresh if nearing expiry 47 + // TODO: dedup with /login 48 + expiryStr := session.Values[appview.SessionExpiry].(string) 49 + expiry, err := time.Parse(time.RFC3339, expiryStr) 50 + if err != nil { 51 + log.Println("invalid expiry time", err) 52 + redirectFunc(w, r) 53 + return 54 + } 55 + pdsUrl, ok1 := session.Values[appview.SessionPds].(string) 56 + did, ok2 := session.Values[appview.SessionDid].(string) 57 + refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string) 58 + 59 + if !ok1 || !ok2 || !ok3 { 60 + log.Println("invalid expiry time", err) 61 + redirectFunc(w, r) 62 + return 63 + } 64 + 65 + if time.Now().After(expiry) { 66 + log.Println("token expired, refreshing ...") 67 + 68 + client := xrpc.Client{ 69 + Host: pdsUrl, 70 + Auth: &xrpc.AuthInfo{ 71 + Did: did, 72 + AccessJwt: refreshJwt, 73 + RefreshJwt: refreshJwt, 74 + }, 75 + } 76 + atSession, err := comatproto.ServerRefreshSession(r.Context(), &client) 77 + if err != nil { 78 + log.Println("failed to refresh session", err) 79 + redirectFunc(w, r) 80 + return 81 + } 82 + 83 + sessionish := auth.RefreshSessionWrapper{atSession} 84 + 85 + err = a.StoreSession(r, w, &sessionish, pdsUrl) 86 + if err != nil { 87 + log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err) 88 + return 89 + } 90 + 91 + log.Println("successfully refreshed token") 92 + } 93 + 94 + next.ServeHTTP(w, r) 95 + }) 96 + } 97 + } 98 + 99 + func Paginate(next http.Handler) http.Handler { 100 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 101 + page := pagination.FirstPage() 102 + 103 + offsetVal := r.URL.Query().Get("offset") 104 + if offsetVal != "" { 105 + offset, err := strconv.Atoi(offsetVal) 106 + if err != nil { 107 + log.Println("invalid offset") 108 + } else { 109 + page.Offset = offset 110 + } 111 + } 112 + 113 + limitVal := r.URL.Query().Get("limit") 114 + if limitVal != "" { 115 + limit, err := strconv.Atoi(limitVal) 116 + if err != nil { 117 + log.Println("invalid limit") 118 + } else { 119 + page.Limit = limit 120 + } 121 + } 122 + 123 + ctx := context.WithValue(r.Context(), "page", page) 124 + next.ServeHTTP(w, r.WithContext(ctx)) 125 + }) 126 + }
-40
appview/pages/chroma.go
··· 1 - package pages 2 - 3 - import "github.com/alecthomas/chroma/v2" 4 - 5 - var tangledTheme map[chroma.TokenType]string = map[chroma.TokenType]string{ 6 - // Keywords 7 - chroma.Keyword: "text-blue-400", 8 - chroma.KeywordConstant: "text-indigo-400", 9 - chroma.KeywordDeclaration: "text-purple-400", 10 - chroma.KeywordNamespace: "text-teal-400", 11 - chroma.KeywordReserved: "text-pink-400", 12 - 13 - // Names 14 - chroma.Name: "text-gray-700", 15 - chroma.NameFunction: "text-green-500", 16 - chroma.NameClass: "text-orange-400", 17 - chroma.NameNamespace: "text-cyan-500", 18 - chroma.NameVariable: "text-red-400", 19 - chroma.NameBuiltin: "text-yellow-500", 20 - 21 - // Literals 22 - chroma.LiteralString: "text-emerald-500 ", 23 - chroma.LiteralStringChar: "text-lime-500", 24 - chroma.LiteralNumber: "text-rose-400", 25 - chroma.LiteralNumberFloat: "text-amber-500", 26 - 27 - // Operators 28 - chroma.Operator: "text-blue-500", 29 - chroma.OperatorWord: "text-indigo-500", 30 - 31 - // Comments 32 - chroma.Comment: "text-gray-500 italic", 33 - chroma.CommentSingle: "text-gray-400 italic", 34 - 35 - // Generic 36 - chroma.GenericError: "text-red-600", 37 - chroma.GenericHeading: "text-purple-500 font-bold", 38 - chroma.GenericDeleted: "text-red-400 line-through", 39 - chroma.GenericInserted: "text-green-400 underline", 40 - }
+6
appview/pages/funcmap.go
··· 13 13 "time" 14 14 15 15 "github.com/dustin/go-humanize" 16 + "tangled.sh/tangled.sh/core/appview/filetree" 16 17 "tangled.sh/tangled.sh/core/appview/pages/markup" 17 18 ) 18 19 ··· 31 32 return strings.Split(s, sep) 32 33 }, 33 34 "add": func(a, b int) int { 35 + return a + b 36 + }, 37 + // the absolute state of go templates 38 + "add64": func(a, b int64) int64 { 34 39 return a + b 35 40 }, 36 41 "sub": func(a, b int) int { ··· 170 175 return template.HTML(data) 171 176 }, 172 177 "cssContentHash": CssContentHash, 178 + "fileTree": filetree.FileTree, 173 179 } 174 180 } 175 181
+226 -82
appview/pages/pages.go
··· 11 11 "io/fs" 12 12 "log" 13 13 "net/http" 14 + "os" 14 15 "path" 15 16 "path/filepath" 16 17 "slices" 17 18 "strings" 18 19 20 + "tangled.sh/tangled.sh/core/appview/auth" 21 + "tangled.sh/tangled.sh/core/appview/db" 22 + "tangled.sh/tangled.sh/core/appview/pages/markup" 23 + "tangled.sh/tangled.sh/core/appview/pagination" 24 + "tangled.sh/tangled.sh/core/appview/state/userutil" 25 + "tangled.sh/tangled.sh/core/patchutil" 26 + "tangled.sh/tangled.sh/core/types" 27 + 19 28 "github.com/alecthomas/chroma/v2" 20 29 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 21 30 "github.com/alecthomas/chroma/v2/lexers" 22 31 "github.com/alecthomas/chroma/v2/styles" 23 32 "github.com/bluesky-social/indigo/atproto/syntax" 24 33 "github.com/microcosm-cc/bluemonday" 25 - "tangled.sh/tangled.sh/core/appview/auth" 26 - "tangled.sh/tangled.sh/core/appview/db" 27 - "tangled.sh/tangled.sh/core/appview/pages/markup" 28 - "tangled.sh/tangled.sh/core/appview/state/userutil" 29 - "tangled.sh/tangled.sh/core/types" 30 34 ) 31 35 32 36 //go:embed templates/* static 33 37 var Files embed.FS 34 38 35 39 type Pages struct { 36 - t map[string]*template.Template 40 + t map[string]*template.Template 41 + dev bool 42 + embedFS embed.FS 43 + templateDir string // Path to templates on disk for dev mode 44 + } 45 + 46 + func NewPages(dev bool) *Pages { 47 + p := &Pages{ 48 + t: make(map[string]*template.Template), 49 + dev: dev, 50 + embedFS: Files, 51 + templateDir: "appview/pages", 52 + } 53 + 54 + // Initial load of all templates 55 + p.loadAllTemplates() 56 + 57 + return p 37 58 } 38 59 39 - func NewPages() *Pages { 60 + func (p *Pages) loadAllTemplates() { 40 61 templates := make(map[string]*template.Template) 62 + var fragmentPaths []string 41 63 42 - // Walk through embedded templates directory and parse all .html files 43 - err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { 64 + // Use embedded FS for initial loading 65 + // First, collect all fragment paths 66 + err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 44 67 if err != nil { 45 68 return err 46 69 } 47 - 48 - if !d.IsDir() && strings.HasSuffix(path, ".html") { 49 - name := strings.TrimPrefix(path, "templates/") 50 - name = strings.TrimSuffix(name, ".html") 70 + if d.IsDir() { 71 + return nil 72 + } 73 + if !strings.HasSuffix(path, ".html") { 74 + return nil 75 + } 76 + if !strings.Contains(path, "fragments/") { 77 + return nil 78 + } 79 + name := strings.TrimPrefix(path, "templates/") 80 + name = strings.TrimSuffix(name, ".html") 81 + tmpl, err := template.New(name). 82 + Funcs(funcMap()). 83 + ParseFS(p.embedFS, path) 84 + if err != nil { 85 + log.Fatalf("setting up fragment: %v", err) 86 + } 87 + templates[name] = tmpl 88 + fragmentPaths = append(fragmentPaths, path) 89 + log.Printf("loaded fragment: %s", name) 90 + return nil 91 + }) 92 + if err != nil { 93 + log.Fatalf("walking template dir for fragments: %v", err) 94 + } 51 95 52 - // add fragments as templates 53 - if strings.HasPrefix(path, "templates/fragments/") { 54 - tmpl, err := template.New(name). 55 - Funcs(funcMap()). 56 - ParseFS(Files, path) 57 - if err != nil { 58 - return fmt.Errorf("setting up fragment: %w", err) 59 - } 96 + // Then walk through and setup the rest of the templates 97 + err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 98 + if err != nil { 99 + return err 100 + } 101 + if d.IsDir() { 102 + return nil 103 + } 104 + if !strings.HasSuffix(path, "html") { 105 + return nil 106 + } 107 + // Skip fragments as they've already been loaded 108 + if strings.Contains(path, "fragments/") { 109 + return nil 110 + } 111 + // Skip layouts 112 + if strings.Contains(path, "layouts/") { 113 + return nil 114 + } 115 + name := strings.TrimPrefix(path, "templates/") 116 + name = strings.TrimSuffix(name, ".html") 117 + // Add the page template on top of the base 118 + allPaths := []string{} 119 + allPaths = append(allPaths, "templates/layouts/*.html") 120 + allPaths = append(allPaths, fragmentPaths...) 121 + allPaths = append(allPaths, path) 122 + tmpl, err := template.New(name). 123 + Funcs(funcMap()). 124 + ParseFS(p.embedFS, allPaths...) 125 + if err != nil { 126 + return fmt.Errorf("setting up template: %w", err) 127 + } 128 + templates[name] = tmpl 129 + log.Printf("loaded template: %s", name) 130 + return nil 131 + }) 132 + if err != nil { 133 + log.Fatalf("walking template dir: %v", err) 134 + } 60 135 61 - templates[name] = tmpl 62 - log.Printf("loaded fragment: %s", name) 63 - } 136 + log.Printf("total templates loaded: %d", len(templates)) 137 + p.t = templates 138 + } 64 139 65 - // layouts and fragments are applied first 66 - if !strings.HasPrefix(path, "templates/layouts/") && 67 - !strings.HasPrefix(path, "templates/fragments/") { 68 - // Add the page template on top of the base 69 - tmpl, err := template.New(name). 70 - Funcs(funcMap()). 71 - ParseFS(Files, "templates/layouts/*.html", "templates/fragments/*.html", path) 72 - if err != nil { 73 - return fmt.Errorf("setting up template: %w", err) 74 - } 140 + // loadTemplateFromDisk loads a template from the filesystem in dev mode 141 + func (p *Pages) loadTemplateFromDisk(name string) error { 142 + if !p.dev { 143 + return nil 144 + } 75 145 76 - templates[name] = tmpl 77 - log.Printf("loaded template: %s", name) 78 - } 146 + log.Printf("reloading template from disk: %s", name) 79 147 148 + // Find all fragments first 149 + var fragmentPaths []string 150 + err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 151 + if err != nil { 152 + return err 153 + } 154 + if d.IsDir() { 80 155 return nil 81 156 } 157 + if !strings.HasSuffix(path, ".html") { 158 + return nil 159 + } 160 + if !strings.Contains(path, "fragments/") { 161 + return nil 162 + } 163 + fragmentPaths = append(fragmentPaths, path) 82 164 return nil 83 165 }) 84 166 if err != nil { 85 - log.Fatalf("walking template dir: %v", err) 167 + return fmt.Errorf("walking disk template dir for fragments: %w", err) 168 + } 169 + 170 + // Find the template path on disk 171 + templatePath := filepath.Join(p.templateDir, "templates", name+".html") 172 + if _, err := os.Stat(templatePath); os.IsNotExist(err) { 173 + return fmt.Errorf("template not found on disk: %s", name) 174 + } 175 + 176 + // Create a new template 177 + tmpl := template.New(name).Funcs(funcMap()) 178 + 179 + // Parse layouts 180 + layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 181 + layouts, err := filepath.Glob(layoutGlob) 182 + if err != nil { 183 + return fmt.Errorf("finding layout templates: %w", err) 86 184 } 87 185 88 - log.Printf("total templates loaded: %d", len(templates)) 186 + // Create paths for parsing 187 + allFiles := append(layouts, fragmentPaths...) 188 + allFiles = append(allFiles, templatePath) 89 189 90 - return &Pages{ 91 - t: templates, 190 + // Parse all templates 191 + tmpl, err = tmpl.ParseFiles(allFiles...) 192 + if err != nil { 193 + return fmt.Errorf("parsing template files: %w", err) 92 194 } 195 + 196 + // Update the template in the map 197 + p.t[name] = tmpl 198 + log.Printf("template reloaded from disk: %s", name) 199 + return nil 93 200 } 94 201 95 - type LoginParams struct { 202 + func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 203 + // In dev mode, reload the template from disk before executing 204 + if p.dev { 205 + if err := p.loadTemplateFromDisk(templateName); err != nil { 206 + log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 207 + // Continue with the existing template 208 + } 209 + } 210 + 211 + tmpl, exists := p.t[templateName] 212 + if !exists { 213 + return fmt.Errorf("template not found: %s", templateName) 214 + } 215 + 216 + if base == "" { 217 + return tmpl.Execute(w, params) 218 + } else { 219 + return tmpl.ExecuteTemplate(w, base, params) 220 + } 96 221 } 97 222 98 223 func (p *Pages) execute(name string, w io.Writer, params any) error { 99 - return p.t[name].ExecuteTemplate(w, "layouts/base", params) 224 + return p.executeOrReload(name, w, "layouts/base", params) 100 225 } 101 226 102 227 func (p *Pages) executePlain(name string, w io.Writer, params any) error { 103 - return p.t[name].Execute(w, params) 228 + return p.executeOrReload(name, w, "", params) 104 229 } 105 230 106 231 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 107 - return p.t[name].ExecuteTemplate(w, "layouts/repobase", params) 232 + return p.executeOrReload(name, w, "layouts/repobase", params) 233 + } 234 + 235 + type LoginParams struct { 108 236 } 109 237 110 238 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 200 328 } 201 329 202 330 func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 203 - return p.executePlain("fragments/follow", w, params) 331 + return p.executePlain("user/fragments/follow", w, params) 204 332 } 205 333 206 334 type RepoActionsFragmentParams struct { ··· 210 338 } 211 339 212 340 func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 213 - return p.executePlain("fragments/repoActions", w, params) 341 + return p.executePlain("repo/fragments/repoActions", w, params) 214 342 } 215 343 216 344 type RepoDescriptionParams struct { ··· 218 346 } 219 347 220 348 func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 221 - return p.executePlain("fragments/editRepoDescription", w, params) 349 + return p.executePlain("repo/fragments/editRepoDescription", w, params) 222 350 } 223 351 224 352 func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 225 - return p.executePlain("fragments/repoDescription", w, params) 353 + return p.executePlain("repo/fragments/repoDescription", w, params) 226 354 } 227 355 228 356 type RepoInfo struct { ··· 378 506 } 379 507 380 508 type RepoCommitParams struct { 381 - LoggedInUser *auth.User 382 - RepoInfo RepoInfo 383 - Active string 384 - types.RepoCommitResponse 509 + LoggedInUser *auth.User 510 + RepoInfo RepoInfo 511 + Active string 385 512 EmailToDidOrHandle map[string]string 513 + 514 + types.RepoCommitResponse 386 515 } 387 516 388 517 func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { ··· 458 587 } 459 588 460 589 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 461 - style := styles.Get("bw") 462 - b := style.Builder() 463 - b.Add(chroma.LiteralString, "noitalic") 464 - style, _ = b.Build() 590 + var style *chroma.Style = styles.Get("catpuccin-latte") 465 591 466 592 if params.ShowRendered { 467 593 switch markup.GetFormat(params.Path) { ··· 477 603 chromahtml.WithLineNumbers(true), 478 604 chromahtml.WithLinkableLineNumbers(true, "L"), 479 605 chromahtml.Standalone(false), 606 + chromahtml.WithClasses(true), 480 607 ) 481 608 482 609 lexer := lexers.Get(filepath.Base(params.Path)) ··· 525 652 } 526 653 527 654 type RepoIssuesParams struct { 528 - LoggedInUser *auth.User 529 - RepoInfo RepoInfo 530 - Active string 531 - Issues []db.Issue 532 - DidHandleMap map[string]string 533 - 655 + LoggedInUser *auth.User 656 + RepoInfo RepoInfo 657 + Active string 658 + Issues []db.Issue 659 + DidHandleMap map[string]string 660 + Page pagination.Page 534 661 FilteringByOpen bool 535 662 } 536 663 ··· 580 707 } 581 708 582 709 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 583 - return p.executePlain("fragments/editIssueComment", w, params) 710 + return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 584 711 } 585 712 586 713 type SingleIssueCommentParams struct { ··· 592 719 } 593 720 594 721 func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 595 - return p.executePlain("fragments/issueComment", w, params) 722 + return p.executePlain("repo/issues/fragments/issueComment", w, params) 596 723 } 597 724 598 725 type RepoNewPullParams struct { ··· 610 737 type RepoPullsParams struct { 611 738 LoggedInUser *auth.User 612 739 RepoInfo RepoInfo 613 - Pulls []db.Pull 740 + Pulls []*db.Pull 614 741 Active string 615 742 DidHandleMap map[string]string 616 743 FilteringBy db.PullState ··· 640 767 } 641 768 642 769 type RepoSinglePullParams struct { 643 - LoggedInUser *auth.User 644 - RepoInfo RepoInfo 645 - Active string 646 - DidHandleMap map[string]string 647 - Pull *db.Pull 648 - PullSourceRepo *db.Repo 649 - MergeCheck types.MergeCheckResponse 650 - ResubmitCheck ResubmitResult 770 + LoggedInUser *auth.User 771 + RepoInfo RepoInfo 772 + Active string 773 + DidHandleMap map[string]string 774 + Pull *db.Pull 775 + MergeCheck types.MergeCheckResponse 776 + ResubmitCheck ResubmitResult 651 777 } 652 778 653 779 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 660 786 DidHandleMap map[string]string 661 787 RepoInfo RepoInfo 662 788 Pull *db.Pull 663 - Diff types.NiceDiff 789 + Diff *types.NiceDiff 664 790 Round int 665 791 Submission *db.PullSubmission 666 792 } ··· 670 796 return p.execute("repo/pulls/patch", w, params) 671 797 } 672 798 799 + type RepoPullInterdiffParams struct { 800 + LoggedInUser *auth.User 801 + DidHandleMap map[string]string 802 + RepoInfo RepoInfo 803 + Pull *db.Pull 804 + Round int 805 + Interdiff *patchutil.InterdiffResult 806 + } 807 + 808 + // this name is a mouthful 809 + func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error { 810 + return p.execute("repo/pulls/interdiff", w, params) 811 + } 812 + 673 813 type PullPatchUploadParams struct { 674 814 RepoInfo RepoInfo 675 815 } 676 816 677 817 func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 678 - return p.executePlain("fragments/pullPatchUpload", w, params) 818 + return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 679 819 } 680 820 681 821 type PullCompareBranchesParams struct { ··· 684 824 } 685 825 686 826 func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 687 - return p.executePlain("fragments/pullCompareBranches", w, params) 827 + return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 688 828 } 689 829 690 830 type PullCompareForkParams struct { ··· 693 833 } 694 834 695 835 func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 696 - return p.executePlain("fragments/pullCompareForks", w, params) 836 + return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 697 837 } 698 838 699 839 type PullCompareForkBranchesParams struct { ··· 703 843 } 704 844 705 845 func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 706 - return p.executePlain("fragments/pullCompareForksBranches", w, params) 846 + return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 707 847 } 708 848 709 849 type PullResubmitParams struct { ··· 714 854 } 715 855 716 856 func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 717 - return p.executePlain("fragments/pullResubmit", w, params) 857 + return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 718 858 } 719 859 720 860 type PullActionsParams struct { ··· 727 867 } 728 868 729 869 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 730 - return p.executePlain("fragments/pullActions", w, params) 870 + return p.executePlain("repo/pulls/fragments/pullActions", w, params) 731 871 } 732 872 733 873 type PullNewCommentParams struct { ··· 738 878 } 739 879 740 880 func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 741 - return p.executePlain("fragments/pullNewComment", w, params) 881 + return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 742 882 } 743 883 744 884 func (p *Pages) Static() http.Handler { 885 + if p.dev { 886 + return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 887 + } 888 + 745 889 sub, err := fs.Sub(Files, "static") 746 890 if err != nil { 747 891 log.Fatalf("no static dir found? that's crazy: %v", err)
-33
appview/pages/templates/fragments/cloneInstructions.html
··· 1 - {{ define "fragments/cloneInstructions" }} 2 - <section class="mt-4 p-6 rounded bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4"> 3 - <div class="flex flex-col gap-2"> 4 - <strong>push</strong> 5 - <div class="md:pl-4 overflow-x-auto whitespace-nowrap"> 6 - <code class="dark:text-gray-100">git remote add origin git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 7 - </div> 8 - </div> 9 - 10 - <div class="flex flex-col gap-2"> 11 - <strong>clone</strong> 12 - <div class="md:pl-4 flex flex-col gap-2"> 13 - 14 - <div class="flex items-center gap-3"> 15 - <span class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white">HTTP</span> 16 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 17 - <code class="dark:text-gray-100">git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 18 - </div> 19 - </div> 20 - 21 - <div class="flex items-center gap-3"> 22 - <span class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white">SSH</span> 23 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 24 - <code class="dark:text-gray-100">git clone git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 25 - </div> 26 - </div> 27 - </div> 28 - </div> 29 - 30 - 31 - <p class="py-2 text-gray-500 dark:text-gray-400">Note that for self-hosted knots, clone URLs may be different based on your setup.</p> 32 - </section> 33 - {{ end }}
-116
appview/pages/templates/fragments/diff.html
··· 1 - {{ define "fragments/diff" }} 2 - {{ $repo := index . 0 }} 3 - {{ $diff := index . 1 }} 4 - {{ $commit := $diff.Commit }} 5 - {{ $stat := $diff.Stat }} 6 - {{ $diff := $diff.Diff }} 7 - 8 - {{ $this := $commit.This }} 9 - {{ $parent := $commit.Parent }} 10 - 11 - {{ $last := sub (len $diff) 1 }} 12 - {{ range $idx, $hunk := $diff }} 13 - {{ with $hunk }} 14 - <section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 15 - <div id="file-{{ .Name.New }}"> 16 - <div id="diff-file"> 17 - <details open> 18 - <summary class="list-none cursor-pointer sticky top-0"> 19 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 20 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto" style="direction: rtl;"> 21 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 22 - 23 - <div class="flex gap-2 items-center" style="direction: ltr;"> 24 - {{ if .IsNew }} 25 - <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span> 26 - {{ else if .IsDelete }} 27 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span> 28 - {{ else if .IsCopy }} 29 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span> 30 - {{ else if .IsRename }} 31 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span> 32 - {{ else }} 33 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span> 34 - {{ end }} 35 - 36 - {{ if .IsDelete }} 37 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}> 38 - {{ .Name.Old }} 39 - </a> 40 - {{ else if (or .IsCopy .IsRename) }} 41 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}> 42 - {{ .Name.Old }} 43 - </a> 44 - {{ i "arrow-right" "w-4 h-4" }} 45 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 46 - {{ .Name.New }} 47 - </a> 48 - {{ else }} 49 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 50 - {{ .Name.New }} 51 - </a> 52 - {{ end }} 53 - </div> 54 - </div> 55 - 56 - {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 57 - <div id="right-side-items" class="p-2 flex items-center"> 58 - <a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 59 - {{ if gt $idx 0 }} 60 - {{ $prev := index $diff (sub $idx 1) }} 61 - <a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 62 - {{ end }} 63 - 64 - {{ if lt $idx $last }} 65 - {{ $next := index $diff (add $idx 1) }} 66 - <a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 67 - {{ end }} 68 - </div> 69 - 70 - </div> 71 - </summary> 72 - 73 - <div class="transition-all duration-700 ease-in-out"> 74 - {{ if .IsDelete }} 75 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 76 - This file has been deleted in this commit. 77 - </p> 78 - {{ else }} 79 - {{ if .IsBinary }} 80 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 81 - This is a binary file and will not be displayed. 82 - </p> 83 - {{ else }} 84 - <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none">{{- .Header -}}</div>{{- range .Lines -}} 85 - {{- if eq .Op.String "+" -}} 86 - <div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full"> 87 - <div class="w-10 flex-shrink-0 select-none p-1 text-center">{{ .Op.String }}</div> 88 - <div class="p-1 whitespace-pre">{{ .Line }}</div> 89 - </div> 90 - {{- end -}} 91 - {{- if eq .Op.String "-" -}} 92 - <div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full"> 93 - <div class="w-10 flex-shrink-0 select-none p-1 text-center">{{ .Op.String }}</div> 94 - <div class="p-1 whitespace-pre">{{ .Line }}</div> 95 - </div> 96 - {{- end -}} 97 - {{- if eq .Op.String " " -}} 98 - <div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full"> 99 - <div class="w-10 flex-shrink-0 select-none p-1 text-center">{{ .Op.String }}</div> 100 - <div class="p-1 whitespace-pre">{{ .Line }}</div> 101 - </div> 102 - {{- end -}} 103 - {{- end -}} 104 - {{- end -}}</div></div></pre> 105 - {{- end -}} 106 - {{ end }} 107 - </div> 108 - 109 - </details> 110 - 111 - </div> 112 - </div> 113 - </section> 114 - {{ end }} 115 - {{ end }} 116 - {{ end }}
-52
appview/pages/templates/fragments/editIssueComment.html
··· 1 - {{ define "fragments/editIssueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 text-sm"> 5 - {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 - 8 - <!-- show user "hats" --> 9 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 - {{ if $isIssueAuthor }} 11 - <span class="before:content-['ยท']"></span> 12 - <span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center"> 13 - author 14 - </span> 15 - {{ end }} 16 - 17 - <span class="before:content-['ยท']"></span> 18 - <a 19 - href="#{{ .CommentId }}" 20 - class="text-gray-500 hover:text-gray-500 hover:underline no-underline" 21 - id="{{ .CommentId }}"> 22 - {{ .Created | timeFmt }} 23 - </a> 24 - 25 - <button 26 - class="btn px-2 py-1 flex items-center gap-2 text-sm" 27 - hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 28 - hx-include="#edit-textarea-{{ .CommentId }}" 29 - hx-target="#comment-container-{{ .CommentId }}" 30 - hx-swap="outerHTML"> 31 - {{ i "check" "w-4 h-4" }} 32 - </button> 33 - <button 34 - class="btn px-2 py-1 flex items-center gap-2 text-sm" 35 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 36 - hx-target="#comment-container-{{ .CommentId }}" 37 - hx-swap="outerHTML"> 38 - {{ i "x" "w-4 h-4" }} 39 - </button> 40 - <span id="comment-{{.CommentId}}-status"></span> 41 - </div> 42 - 43 - <div> 44 - <textarea 45 - id="edit-textarea-{{ .CommentId }}" 46 - name="body" 47 - class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea> 48 - </div> 49 - </div> 50 - {{ end }} 51 - {{ end }} 52 -
-11
appview/pages/templates/fragments/editRepoDescription.html
··· 1 - {{ define "fragments/editRepoDescription" }} 2 - <form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2"> 3 - <input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}"> 4 - <button type="submit" class="btn p-2 flex items-center gap-2 no-underline text-sm"> 5 - {{ i "check" "w-3 h-3" }} save 6 - </button> 7 - <button type="button" class="btn p-2 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" > 8 - {{ i "x" "w-3 h-3" }} cancel 9 - </button> 10 - </form> 11 - {{ end }}
-17
appview/pages/templates/fragments/follow.html
··· 1 - {{ define "fragments/follow" }} 2 - <button id="followBtn" 3 - class="btn mt-2 w-full" 4 - 5 - {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 - hx-post="/follow?subject={{.UserDid}}" 7 - {{ else }} 8 - hx-delete="/follow?subject={{.UserDid}}" 9 - {{ end }} 10 - 11 - hx-trigger="click" 12 - hx-target="#followBtn" 13 - hx-swap="outerHTML" 14 - > 15 - {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }} 16 - </button> 17 - {{ end }}
-60
appview/pages/templates/fragments/issueComment.html
··· 1 - {{ define "fragments/issueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 text-sm"> 5 - {{ $owner := index $.DidHandleMap .OwnerDid }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 - 8 - <!-- show user "hats" --> 9 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 - {{ if $isIssueAuthor }} 11 - <span class="before:content-['ยท']"></span> 12 - <span class="rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 13 - author 14 - </span> 15 - {{ end }} 16 - 17 - <span class="before:content-['ยท']"></span> 18 - <a 19 - href="#{{ .CommentId }}" 20 - class="text-gray-500 hover:text-gray-500 hover:underline no-underline" 21 - id="{{ .CommentId }}"> 22 - {{ if .Deleted }} 23 - deleted {{ .Deleted | timeFmt }} 24 - {{ else if .Edited }} 25 - edited {{ .Edited | timeFmt }} 26 - {{ else }} 27 - {{ .Created | timeFmt }} 28 - {{ end }} 29 - </a> 30 - 31 - {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 32 - {{ if and $isCommentOwner (not .Deleted) }} 33 - <button 34 - class="btn px-2 py-1 text-sm" 35 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 36 - hx-swap="outerHTML" 37 - hx-target="#comment-container-{{.CommentId}}" 38 - > 39 - {{ i "pencil" "w-4 h-4" }} 40 - </button> 41 - <button 42 - class="btn px-2 py-1 text-sm text-red-500" 43 - hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 44 - hx-confirm="Are you sure you want to delete your comment?" 45 - hx-swap="outerHTML" 46 - hx-target="#comment-container-{{.CommentId}}" 47 - > 48 - {{ i "trash-2" "w-4 h-4" }} 49 - </button> 50 - {{ end }} 51 - 52 - </div> 53 - {{ if not .Deleted }} 54 - <div class="prose dark:prose-invert"> 55 - {{ .Body | markdown }} 56 - </div> 57 - {{ end }} 58 - </div> 59 - {{ end }} 60 - {{ end }}
-91
appview/pages/templates/fragments/pullActions.html
··· 1 - {{ define "fragments/pullActions" }} 2 - {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 3 - {{ $roundNumber := .RoundNumber }} 4 - 5 - {{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }} 6 - {{ $isMerged := .Pull.State.IsMerged }} 7 - {{ $isClosed := .Pull.State.IsClosed }} 8 - {{ $isOpen := .Pull.State.IsOpen }} 9 - {{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }} 10 - {{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }} 11 - {{ $isLastRound := eq $roundNumber $lastIdx }} 12 - {{ $isSameRepoBranch := .Pull.IsSameRepoBranch }} 13 - {{ $isUpToDate := .ResubmitCheck.No }} 14 - <div class="relative w-fit"> 15 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 16 - <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2"> 17 - <button 18 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 19 - hx-target="#actions-{{$roundNumber}}" 20 - hx-swap="outerHtml" 21 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline"> 22 - {{ i "message-square-plus" "w-4 h-4" }} 23 - <span>comment</span> 24 - </button> 25 - {{ if and $isPushAllowed $isOpen $isLastRound }} 26 - {{ $disabled := "" }} 27 - {{ if $isConflicted }} 28 - {{ $disabled = "disabled" }} 29 - {{ end }} 30 - <button 31 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 32 - hx-swap="none" 33 - hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 34 - class="btn p-2 flex items-center gap-2" {{ $disabled }}> 35 - {{ i "git-merge" "w-4 h-4" }} 36 - <span>merge</span> 37 - </button> 38 - {{ end }} 39 - 40 - {{ if and $isPullAuthor $isOpen $isLastRound }} 41 - {{ $disabled := "" }} 42 - {{ if $isUpToDate }} 43 - {{ $disabled = "disabled" }} 44 - {{ end }} 45 - <button id="resubmitBtn" 46 - {{ if not .Pull.IsPatch }} 47 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 48 - {{ else }} 49 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 50 - hx-target="#actions-{{$roundNumber}}" 51 - hx-swap="outerHtml" 52 - {{ end }} 53 - 54 - hx-disabled-elt="#resubmitBtn" 55 - class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" {{ $disabled }} 56 - 57 - {{ if $disabled }} 58 - title="Update this branch to resubmit this pull request" 59 - {{ else }} 60 - title="Resubmit this pull request" 61 - {{ end }} 62 - > 63 - {{ i "rotate-ccw" "w-4 h-4" }} 64 - <span>resubmit</span> 65 - </button> 66 - {{ end }} 67 - 68 - {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 69 - <button 70 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 71 - hx-swap="none" 72 - class="btn p-2 flex items-center gap-2"> 73 - {{ i "ban" "w-4 h-4" }} 74 - <span>close</span> 75 - </button> 76 - {{ end }} 77 - 78 - {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 79 - <button 80 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 81 - hx-swap="none" 82 - class="btn p-2 flex items-center gap-2"> 83 - {{ i "circle-dot" "w-4 h-4" }} 84 - <span>reopen</span> 85 - </button> 86 - {{ end }} 87 - </div> 88 - </div> 89 - {{ end }} 90 - 91 -
-20
appview/pages/templates/fragments/pullCompareBranches.html
··· 1 - {{ define "fragments/pullCompareBranches" }} 2 - <div id="patch-upload"> 3 - <label for="targetBranch" class="dark:text-white" 4 - >select a branch</label 5 - > 6 - <div class="flex flex-wrap gap-2 items-center"> 7 - <select 8 - name="sourceBranch" 9 - class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 10 - > 11 - <option disabled selected>source branch</option> 12 - {{ range .Branches }} 13 - <option value="{{ .Reference.Name }}" class="py-1"> 14 - {{ .Reference.Name }} 15 - </option> 16 - {{ end }} 17 - </select> 18 - </div> 19 - </div> 20 - {{ end }}
-42
appview/pages/templates/fragments/pullCompareForks.html
··· 1 - {{ define "fragments/pullCompareForks" }} 2 - <div id="patch-upload"> 3 - <label for="forkSelect" class="dark:text-white" 4 - >select a fork to compare</label 5 - > 6 - <div class="flex flex-wrap gap-4 items-center mb-4"> 7 - <div class="flex flex-wrap gap-2 items-center"> 8 - <select 9 - id="forkSelect" 10 - name="fork" 11 - required 12 - class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 13 - hx-get="/{{ $.RepoInfo.FullName }}/pulls/new/fork-branches" 14 - hx-target="#branch-selection" 15 - hx-vals='{"fork": this.value}' 16 - hx-swap="innerHTML" 17 - onchange="document.getElementById('hiddenForkInput').value = this.value;" 18 - > 19 - <option disabled selected>select a fork</option> 20 - {{ range .Forks }} 21 - <option value="{{ .Name }}" class="py-1"> 22 - {{ .Name }} 23 - </option> 24 - {{ end }} 25 - </select> 26 - 27 - <input 28 - type="hidden" 29 - id="hiddenForkInput" 30 - name="fork" 31 - value="" 32 - /> 33 - </div> 34 - 35 - <div id="branch-selection"> 36 - <div class="text-sm text-gray-500 dark:text-gray-400"> 37 - Select a fork first to view available branches 38 - </div> 39 - </div> 40 - </div> 41 - </div> 42 - {{ end }}
-15
appview/pages/templates/fragments/pullCompareForksBranches.html
··· 1 - {{ define "fragments/pullCompareForksBranches" }} 2 - <div class="flex flex-wrap gap-2 items-center"> 3 - <select 4 - name="sourceBranch" 5 - class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 6 - > 7 - <option disabled selected>source branch</option> 8 - {{ range .SourceBranches }} 9 - <option value="{{ .Reference.Name }}" class="py-1"> 10 - {{ .Reference.Name }} 11 - </option> 12 - {{ end }} 13 - </select> 14 - </div> 15 - {{ end }}
-32
appview/pages/templates/fragments/pullNewComment.html
··· 1 - {{ define "fragments/pullNewComment" }} 2 - <div 3 - id="pull-comment-card-{{ .RoundNumber }}" 4 - class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 - <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 7 - </div> 8 - <form 9 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 10 - hx-swap="none" 11 - class="w-full flex flex-wrap gap-2"> 12 - <textarea 13 - name="body" 14 - class="w-full p-2 rounded border border-gray-200" 15 - placeholder="Add to the discussion..."></textarea> 16 - <button type="submit" class="btn flex items-center gap-2"> 17 - {{ i "message-square" "w-4 h-4" }} comment 18 - </button> 19 - <button 20 - type="button" 21 - class="btn flex items-center gap-2" 22 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 23 - hx-swap="outerHTML" 24 - hx-target="#pull-comment-card-{{ .RoundNumber }}"> 25 - {{ i "x" "w-4 h-4" }} 26 - <span>cancel</span> 27 - </button> 28 - <div id="pull-comment"></div> 29 - </form> 30 - </div> 31 - {{ end }} 32 -
-14
appview/pages/templates/fragments/pullPatchUpload.html
··· 1 - {{ define "fragments/pullPatchUpload" }} 2 - <div id="patch-upload"> 3 - <textarea 4 - name="patch" 5 - id="patch" 6 - rows="12" 7 - class="w-full resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600" 8 - placeholder="diff --git a/file.txt b/file.txt 9 - index 1234567..abcdefg 100644 10 - --- a/file.txt 11 - +++ b/file.txt" 12 - ></textarea> 13 - </div> 14 - {{ end }}
-52
appview/pages/templates/fragments/pullResubmit.html
··· 1 - {{ define "fragments/pullResubmit" }} 2 - <div 3 - id="resubmit-pull-card" 4 - class="rounded relative border bg-amber-50 dark:bg-amber-900 border-amber-200 dark:border-amber-500 px-6 py-2"> 5 - 6 - <div class="flex items-center gap-2 text-amber-500 dark:text-amber-50"> 7 - {{ i "pencil" "w-4 h-4" }} 8 - <span class="font-medium">resubmit your patch</span> 9 - </div> 10 - 11 - <div class="mt-2 text-sm text-gray-700 dark:text-gray-200"> 12 - You can update this patch to address any reviews. 13 - This will begin a new round of reviews, 14 - but you'll still be able to view your previous submissions and feedback. 15 - </div> 16 - 17 - <div class="mt-4 flex flex-col"> 18 - <form 19 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 20 - hx-swap="none" 21 - class="w-full flex flex-wrap gap-2"> 22 - <textarea 23 - name="patch" 24 - class="w-full p-2 mb-2" 25 - placeholder="Paste your updated patch here." 26 - rows="15" 27 - >{{.Pull.LatestPatch}}</textarea> 28 - <button 29 - type="submit" 30 - class="btn flex items-center gap-2" 31 - {{ if or .Pull.State.IsClosed }} 32 - disabled 33 - {{ end }}> 34 - {{ i "rotate-ccw" "w-4 h-4" }} 35 - <span>resubmit</span> 36 - </button> 37 - <button 38 - type="button" 39 - class="btn flex items-center gap-2" 40 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions" 41 - hx-swap="outerHTML" 42 - hx-target="#resubmit-pull-card"> 43 - {{ i "x" "w-4 h-4" }} 44 - <span>cancel</span> 45 - </button> 46 - </form> 47 - 48 - <div id="resubmit-error" class="error"></div> 49 - <div id="resubmit-success" class="success"></div> 50 - </div> 51 - </div> 52 - {{ end }}
-41
appview/pages/templates/fragments/repoActions.html
··· 1 - {{ define "fragments/repoActions" }} 2 - <div class="flex items-center gap-2 z-auto"> 3 - <button id="starBtn" 4 - class="btn disabled:opacity-50 disabled:cursor-not-allowed" 5 - 6 - {{ if .IsStarred }} 7 - hx-delete="/star?subject={{.RepoAt}}&countHint={{.Stats.StarCount}}" 8 - {{ else }} 9 - hx-post="/star?subject={{.RepoAt}}&countHint={{.Stats.StarCount}}" 10 - {{ end }} 11 - 12 - hx-trigger="click" 13 - hx-target="#starBtn" 14 - hx-swap="outerHTML" 15 - hx-disabled-elt="#starBtn" 16 - > 17 - <div class="flex gap-2 items-center"> 18 - {{ if .IsStarred }} 19 - {{ i "star" "w-4 h-4 fill-current" }} 20 - {{ else }} 21 - {{ i "star" "w-4 h-4" }} 22 - {{ end }} 23 - <span> 24 - {{ .Stats.StarCount }} 25 - </span> 26 - </div> 27 - </button> 28 - {{ if .DisableFork }} 29 - <button class="btn no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" disabled title="Empty repositories cannot be forked"> 30 - {{ i "git-fork" "w-4 h-4"}} 31 - fork 32 - </button> 33 - {{ else }} 34 - <a class="btn no-underline hover:no-underline flex items-center gap-2" href="/{{ .FullName }}/fork"> 35 - {{ i "git-fork" "w-4 h-4"}} 36 - fork 37 - </a> 38 - {{ end }} 39 - </div> 40 - {{ end }} 41 -
-15
appview/pages/templates/fragments/repoDescription.html
··· 1 - {{ define "fragments/repoDescription" }} 2 - <span id="repo-description" class="flex flex-wrap items-center gap-2" hx-target="this" hx-swap="outerHTML"> 3 - {{ if .RepoInfo.Description }} 4 - {{ .RepoInfo.Description }} 5 - {{ else }} 6 - <span class="italic">this repo has no description</span> 7 - {{ end }} 8 - 9 - {{ if .RepoInfo.Roles.IsOwner }} 10 - <button class="btn p-2 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit"> 11 - {{ i "pencil" "w-3 h-3" }} edit 12 - </button> 13 - {{ end }} 14 - </span> 15 - {{ end }}
+2 -2
appview/pages/templates/layouts/repobase.html
··· 19 19 <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 20 20 </div> 21 21 22 - {{ template "fragments/repoActions" .RepoInfo }} 22 + {{ template "repo/fragments/repoActions" .RepoInfo }} 23 23 </div> 24 - {{ template "fragments/repoDescription" . }} 24 + {{ template "repo/fragments/repoDescription" . }} 25 25 </section> 26 26 <section class="min-h-screen flex flex-col drop-shadow-sm"> 27 27 <nav class="w-full pl-4 overflow-auto">
+1 -1
appview/pages/templates/repo/blob.html
··· 60 60 {{ else }} 61 61 <div class="overflow-auto relative"> 62 62 {{ if .ShowRendered }} 63 - <div id="blob-contents" class="prose dark:prose-invert p-6">{{ .RenderedContents }}</div> 63 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 64 64 {{ else }} 65 65 <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div> 66 66 {{ end }}
+2 -21
appview/pages/templates/repo/commit.html
··· 4 4 5 5 {{ $repo := .RepoInfo.FullName }} 6 6 {{ $commit := .Diff.Commit }} 7 - {{ $stat := .Diff.Stat }} 8 - {{ $diff := .Diff.Diff }} 9 7 10 8 <section class="commit dark:text-white"> 11 9 <div id="commit-message"> ··· 13 11 <div> 14 12 <p class="pb-2">{{ index $messageParts 0 }}</p> 15 13 {{ if gt (len $messageParts) 1 }} 16 - <p class="mt-1 cursor-text pb-2 text-sm">{{ nl2br (unwrapText (index $messageParts 1)) }}</p> 14 + <p class="mt-1 cursor-text pb-2 text-sm">{{ nl2br (index $messageParts 1) }}</p> 17 15 {{ end }} 18 16 </div> 19 17 </div> ··· 29 27 {{ end }} 30 28 <span class="px-1 select-none before:content-['\00B7']"></span> 31 29 {{ timeFmt $commit.Author.When }} 32 - <span class="px-1 select-none before:content-['\00B7']"></span> 33 - <span>{{ $stat.FilesChanged }}</span> files <span class="font-mono">(+{{ $stat.Insertions }}, -{{ $stat.Deletions }})</span> 34 30 <span class="px-1 select-none before:content-['\00B7']"></span> 35 31 </p> 36 32 ··· 43 39 </p> 44 40 </div> 45 41 46 - <div class="diff-stat"> 47 - <br> 48 - <strong class="text-sm uppercase mb-4 dark:text-gray-200">Changed files</strong> 49 - <div class="overflow-x-auto"> 50 - {{ range $diff }} 51 - <ul class="dark:text-gray-200"> 52 - {{ if .IsDelete }} 53 - <li><a href="#file-{{ .Name.Old }}" class="dark:hover:text-gray-300">{{ .Name.Old }}</a></li> 54 - {{ else }} 55 - <li><a href="#file-{{ .Name.New }}" class="dark:hover:text-gray-300">{{ .Name.New }}</a></li> 56 - {{ end }} 57 - </ul> 58 - {{ end }} 59 - </div> 60 - </div> 61 42 </section> 62 43 63 44 {{end}} 64 45 65 46 {{ define "repoAfter" }} 66 - {{ template "fragments/diff" (list .RepoInfo.FullName .Diff) }} 47 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 67 48 {{end}}
+1 -1
appview/pages/templates/repo/empty.html
··· 9 9 {{ end }} 10 10 11 11 {{ define "repoAfter" }} 12 - {{ template "fragments/cloneInstructions" . }} 12 + {{ template "repo/fragments/cloneInstructions" . }} 13 13 {{ end }}
+51
appview/pages/templates/repo/fragments/cloneInstructions.html
··· 1 + {{ define "repo/fragments/cloneInstructions" }} 2 + <section 3 + class="mt-4 p-6 rounded bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4" 4 + > 5 + <div class="flex flex-col gap-2"> 6 + <strong>push</strong> 7 + <div class="md:pl-4 overflow-x-auto whitespace-nowrap"> 8 + <code class="dark:text-gray-100" 9 + >git remote add origin 10 + git@{{ .RepoInfo.Knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 11 + > 12 + </div> 13 + </div> 14 + 15 + <div class="flex flex-col gap-2"> 16 + <strong>clone</strong> 17 + <div class="md:pl-4 flex flex-col gap-2"> 18 + <div class="flex items-center gap-3"> 19 + <span 20 + class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 21 + >HTTP</span 22 + > 23 + <div class="overflow-x-auto whitespace-nowrap flex-1"> 24 + <code class="dark:text-gray-100" 25 + >git clone 26 + https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code 27 + > 28 + </div> 29 + </div> 30 + 31 + <div class="flex items-center gap-3"> 32 + <span 33 + class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 34 + >SSH</span 35 + > 36 + <div class="overflow-x-auto whitespace-nowrap flex-1"> 37 + <code class="dark:text-gray-100" 38 + >git clone 39 + git@{{ .RepoInfo.Knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 40 + > 41 + </div> 42 + </div> 43 + </div> 44 + </div> 45 + 46 + <p class="py-2 text-gray-500 dark:text-gray-400"> 47 + Note that for self-hosted knots, clone URLs may be different based 48 + on your setup. 49 + </p> 50 + </section> 51 + {{ end }}
+163
appview/pages/templates/repo/fragments/diff.html
··· 1 + {{ define "repo/fragments/diff" }} 2 + {{ $repo := index . 0 }} 3 + {{ $diff := index . 1 }} 4 + {{ $commit := $diff.Commit }} 5 + {{ $stat := $diff.Stat }} 6 + {{ $fileTree := fileTree $diff.ChangedFiles }} 7 + {{ $diff := $diff.Diff }} 8 + 9 + {{ $this := $commit.This }} 10 + {{ $parent := $commit.Parent }} 11 + 12 + <section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 13 + <div class="diff-stat"> 14 + <div class="flex gap-2 items-center"> 15 + <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 16 + {{ block "statPill" $stat }} {{ end }} 17 + </div> 18 + {{ block "fileTree" $fileTree }} {{ end }} 19 + </div> 20 + </section> 21 + 22 + {{ $last := sub (len $diff) 1 }} 23 + {{ range $idx, $hunk := $diff }} 24 + {{ with $hunk }} 25 + <section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 26 + <div id="file-{{ .Name.New }}"> 27 + <div id="diff-file"> 28 + <details open> 29 + <summary class="list-none cursor-pointer sticky top-0"> 30 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 31 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 32 + <div class="flex gap-1 items-center"> 33 + {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 34 + {{ if .IsNew }} 35 + <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span> 36 + {{ else if .IsDelete }} 37 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span> 38 + {{ else if .IsCopy }} 39 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span> 40 + {{ else if .IsRename }} 41 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span> 42 + {{ else }} 43 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span> 44 + {{ end }} 45 + 46 + {{ block "statPill" .Stats }} {{ end }} 47 + </div> 48 + 49 + <div class="flex gap-2 items-center overflow-x-auto"> 50 + {{ if .IsDelete }} 51 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}> 52 + {{ .Name.Old }} 53 + </a> 54 + {{ else if (or .IsCopy .IsRename) }} 55 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}> 56 + {{ .Name.Old }} 57 + </a> 58 + {{ i "arrow-right" "w-4 h-4" }} 59 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 60 + {{ .Name.New }} 61 + </a> 62 + {{ else }} 63 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 64 + {{ .Name.New }} 65 + </a> 66 + {{ end }} 67 + </div> 68 + </div> 69 + 70 + {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 71 + <div id="right-side-items" class="p-2 flex items-center"> 72 + <a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 73 + {{ if gt $idx 0 }} 74 + {{ $prev := index $diff (sub $idx 1) }} 75 + <a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 76 + {{ end }} 77 + 78 + {{ if lt $idx $last }} 79 + {{ $next := index $diff (add $idx 1) }} 80 + <a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 81 + {{ end }} 82 + </div> 83 + 84 + </div> 85 + </summary> 86 + 87 + <div class="transition-all duration-700 ease-in-out"> 88 + {{ if .IsDelete }} 89 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 90 + This file has been deleted. 91 + </p> 92 + {{ else if .IsCopy }} 93 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 94 + This file has been copied. 95 + </p> 96 + {{ else if .IsBinary }} 97 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 98 + This is a binary file and will not be displayed. 99 + </p> 100 + {{ else }} 101 + {{ $name := .Name.New }} 102 + <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 103 + {{- $oldStart := .OldPosition -}} 104 + {{- $newStart := .NewPosition -}} 105 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}} 106 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 107 + {{- $lineNrSepStyle1 := "" -}} 108 + {{- $lineNrSepStyle2 := "pr-2" -}} 109 + {{- range .Lines -}} 110 + {{- if eq .Op.String "+" -}} 111 + <div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center"> 112 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 113 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 114 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 115 + <div class="px-2">{{ .Line }}</div> 116 + </div> 117 + {{- $newStart = add64 $newStart 1 -}} 118 + {{- end -}} 119 + {{- if eq .Op.String "-" -}} 120 + <div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center"> 121 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 122 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 123 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 124 + <div class="px-2">{{ .Line }}</div> 125 + </div> 126 + {{- $oldStart = add64 $oldStart 1 -}} 127 + {{- end -}} 128 + {{- if eq .Op.String " " -}} 129 + <div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center"> 130 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 131 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 132 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 133 + <div class="px-2">{{ .Line }}</div> 134 + </div> 135 + {{- $newStart = add64 $newStart 1 -}} 136 + {{- $oldStart = add64 $oldStart 1 -}} 137 + {{- end -}} 138 + {{- end -}} 139 + {{- end -}}</div></div></pre> 140 + {{- end -}} 141 + </div> 142 + 143 + </details> 144 + 145 + </div> 146 + </div> 147 + </section> 148 + {{ end }} 149 + {{ end }} 150 + {{ end }} 151 + 152 + {{ define "statPill" }} 153 + <div class="flex items-center font-mono text-sm"> 154 + {{ if and .Insertions .Deletions }} 155 + <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 156 + <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 157 + {{ else if .Insertions }} 158 + <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 159 + {{ else if .Deletions }} 160 + <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 161 + {{ end }} 162 + </div> 163 + {{ end }}
+11
appview/pages/templates/repo/fragments/editRepoDescription.html
··· 1 + {{ define "repo/fragments/editRepoDescription" }} 2 + <form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2"> 3 + <input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}"> 4 + <button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm"> 5 + {{ i "check" "w-3 h-3" }} save 6 + </button> 7 + <button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" > 8 + {{ i "x" "w-3 h-3" }} cancel 9 + </button> 10 + </form> 11 + {{ end }}
+27
appview/pages/templates/repo/fragments/filetree.html
··· 1 + {{ define "fileTree" }} 2 + {{ if and .Name .IsDirectory }} 3 + <details open> 4 + <summary class="cursor-pointer list-none pt-1"> 5 + <span class="inline-flex items-center gap-2 "> 6 + {{ i "folder" "w-3 h-3 fill-current" }} 7 + <span class="text-black dark:text-white">{{ .Name }}</span> 8 + </span> 9 + </summary> 10 + <div class="ml-1 pl-4 border-l border-gray-200 dark:border-gray-700"> 11 + {{ range $child := .Children }} 12 + {{ block "fileTree" $child }} {{ end }} 13 + {{ end }} 14 + </div> 15 + </details> 16 + {{ else if .Name }} 17 + <div class="flex items-center gap-2 pt-1"> 18 + {{ i "file" "w-3 h-3" }} 19 + <a href="#file-{{ .Path }}" class="text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 20 + </div> 21 + {{ else }} 22 + {{ range $child := .Children }} 23 + {{ block "fileTree" $child }} {{ end }} 24 + {{ end }} 25 + {{ end }} 26 + {{ end }} 27 +
+143
appview/pages/templates/repo/fragments/interdiff.html
··· 1 + {{ define "repo/fragments/interdiff" }} 2 + {{ $repo := index . 0 }} 3 + {{ $x := index . 1 }} 4 + {{ $fileTree := fileTree $x.AffectedFiles }} 5 + {{ $diff := $x.Files }} 6 + 7 + <section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 8 + <div class="diff-stat"> 9 + <div class="flex gap-2 items-center"> 10 + <strong class="text-sm uppercase dark:text-gray-200">files</strong> 11 + </div> 12 + {{ block "fileTree" $fileTree }} {{ end }} 13 + </div> 14 + </section> 15 + 16 + {{ $last := sub (len $diff) 1 }} 17 + {{ range $idx, $hunk := $diff }} 18 + {{ with $hunk }} 19 + <section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 20 + <div id="file-{{ .Name }}"> 21 + <div id="diff-file"> 22 + <details {{ if not (.Status.IsOnlyInOne) }}open{{end}}> 23 + <summary class="list-none cursor-pointer sticky top-0"> 24 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 25 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 26 + <div class="flex gap-1 items-center" style="direction: ltr;"> 27 + {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 28 + {{ if .Status.IsOk }} 29 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span> 30 + {{ else if .Status.IsUnchanged }} 31 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span> 32 + {{ else if .Status.IsOnlyInOne }} 33 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span> 34 + {{ else if .Status.IsOnlyInTwo }} 35 + <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span> 36 + {{ else if .Status.IsRebased }} 37 + <span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span> 38 + {{ else }} 39 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span> 40 + {{ end }} 41 + </div> 42 + 43 + <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;"> 44 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" href=""> 45 + {{ .Name }} 46 + </a> 47 + </div> 48 + </div> 49 + 50 + {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 51 + <div id="right-side-items" class="p-2 flex items-center"> 52 + <a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 53 + {{ if gt $idx 0 }} 54 + {{ $prev := index $diff (sub $idx 1) }} 55 + <a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 56 + {{ end }} 57 + 58 + {{ if lt $idx $last }} 59 + {{ $next := index $diff (add $idx 1) }} 60 + <a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 61 + {{ end }} 62 + </div> 63 + 64 + </div> 65 + </summary> 66 + 67 + <div class="transition-all duration-700 ease-in-out"> 68 + {{ if .Status.IsUnchanged }} 69 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 70 + This file has not been changed. 71 + </p> 72 + {{ else if .Status.IsRebased }} 73 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 74 + This patch was likely rebased, as context lines do not match. 75 + </p> 76 + {{ else if .Status.IsError }} 77 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 78 + Failed to calculate interdiff for this file. 79 + </p> 80 + {{ else }} 81 + {{ $name := .Name }} 82 + <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 83 + {{- $oldStart := .OldPosition -}} 84 + {{- $newStart := .NewPosition -}} 85 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}} 86 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 87 + {{- $lineNrSepStyle1 := "" -}} 88 + {{- $lineNrSepStyle2 := "pr-2" -}} 89 + {{- range .Lines -}} 90 + {{- if eq .Op.String "+" -}} 91 + <div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center"> 92 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 93 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 94 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 95 + <div class="px-2">{{ .Line }}</div> 96 + </div> 97 + {{- $newStart = add64 $newStart 1 -}} 98 + {{- end -}} 99 + {{- if eq .Op.String "-" -}} 100 + <div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center"> 101 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 102 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 103 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 104 + <div class="px-2">{{ .Line }}</div> 105 + </div> 106 + {{- $oldStart = add64 $oldStart 1 -}} 107 + {{- end -}} 108 + {{- if eq .Op.String " " -}} 109 + <div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center"> 110 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 111 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 112 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 113 + <div class="px-2">{{ .Line }}</div> 114 + </div> 115 + {{- $newStart = add64 $newStart 1 -}} 116 + {{- $oldStart = add64 $oldStart 1 -}} 117 + {{- end -}} 118 + {{- end -}} 119 + {{- end -}}</div></div></pre> 120 + {{- end -}} 121 + </div> 122 + 123 + </details> 124 + 125 + </div> 126 + </div> 127 + </section> 128 + {{ end }} 129 + {{ end }} 130 + {{ end }} 131 + 132 + {{ define "statPill" }} 133 + <div class="flex items-center font-mono text-sm"> 134 + {{ if and .Insertions .Deletions }} 135 + <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 136 + <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 137 + {{ else if .Insertions }} 138 + <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 139 + {{ else if .Deletions }} 140 + <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 141 + {{ end }} 142 + </div> 143 + {{ end }}
+47
appview/pages/templates/repo/fragments/repoActions.html
··· 1 + {{ define "repo/fragments/repoActions" }} 2 + <div class="flex items-center gap-2 z-auto"> 3 + <button 4 + id="starBtn" 5 + class="btn disabled:opacity-50 disabled:cursor-not-allowed" 6 + {{ if .IsStarred }} 7 + hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 8 + {{ else }} 9 + hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 10 + {{ end }} 11 + 12 + hx-trigger="click" 13 + hx-target="#starBtn" 14 + hx-swap="outerHTML" 15 + hx-disabled-elt="#starBtn" 16 + > 17 + <div class="flex gap-2 items-center"> 18 + {{ if .IsStarred }} 19 + {{ i "star" "w-4 h-4 fill-current" }} 20 + {{ else }} 21 + {{ i "star" "w-4 h-4" }} 22 + {{ end }} 23 + <span class="text-sm"> 24 + {{ .Stats.StarCount }} 25 + </span> 26 + </div> 27 + </button> 28 + {{ if .DisableFork }} 29 + <button 30 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 31 + disabled 32 + title="Empty repositories cannot be forked" 33 + > 34 + {{ i "git-fork" "w-4 h-4" }} 35 + fork 36 + </button> 37 + {{ else }} 38 + <a 39 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2" 40 + href="/{{ .FullName }}/fork" 41 + > 42 + {{ i "git-fork" "w-4 h-4" }} 43 + fork 44 + </a> 45 + {{ end }} 46 + </div> 47 + {{ end }}
+15
appview/pages/templates/repo/fragments/repoDescription.html
··· 1 + {{ define "repo/fragments/repoDescription" }} 2 + <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 + {{ if .RepoInfo.Description }} 4 + {{ .RepoInfo.Description }} 5 + {{ else }} 6 + <span class="italic">this repo has no description</span> 7 + {{ end }} 8 + 9 + {{ if .RepoInfo.Roles.IsOwner }} 10 + <button class="flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit"> 11 + {{ i "pencil" "w-3 h-3" }} 12 + </button> 13 + {{ end }} 14 + </span> 15 + {{ end }}
+207 -172
appview/pages/templates/repo/index.html
··· 1 1 {{ define "title" }}{{ .RepoInfo.FullName }} at {{ .Ref }}{{ end }} 2 2 3 - 4 3 {{ define "extrameta" }} 5 - <meta name="vcs:clone" content="https://tangled.sh/{{ .RepoInfo.FullName }}"/> 6 - <meta name="forge:summary" content="https://tangled.sh/{{ .RepoInfo.FullName }}"> 7 - <meta name="forge:dir" content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}"> 8 - <meta name="forge:file" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}"> 9 - <meta name="forge:line" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}"> 10 - <meta name="go-import" content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}"> 4 + <meta 5 + name="vcs:clone" 6 + content="https://tangled.sh/{{ .RepoInfo.FullName }}" 7 + /> 8 + <meta 9 + name="forge:summary" 10 + content="https://tangled.sh/{{ .RepoInfo.FullName }}" 11 + /> 12 + <meta 13 + name="forge:dir" 14 + content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}" 15 + /> 16 + <meta 17 + name="forge:file" 18 + content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}" 19 + /> 20 + <meta 21 + name="forge:line" 22 + content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}" 23 + /> 24 + <meta 25 + name="go-import" 26 + content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}" 27 + /> 11 28 {{ end }} 12 - 13 29 14 30 {{ define "repoContent" }} 15 31 <main> 16 - {{ block "branchSelector" . }} {{ end }} 32 + {{ block "branchSelector" . }}{{ end }} 17 33 <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> 18 - {{ block "fileTree" . }} {{ end }} 19 - {{ block "commitLog" . }} {{ end }} 34 + {{ block "fileTree" . }}{{ end }} 35 + {{ block "commitLog" . }}{{ end }} 20 36 </div> 21 37 </main> 22 38 {{ end }} 23 39 24 40 {{ define "branchSelector" }} 25 - <div class="flex justify-between pb-5"> 26 - <select 27 - onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 28 - class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 29 - > 30 - <optgroup label="branches" class="bold text-sm"> 31 - {{ range .Branches }} 32 - <option 33 - value="{{ .Reference.Name }}" 34 - class="py-1" 35 - {{ if eq .Reference.Name $.Ref }} 36 - selected 37 - {{ end }} 38 - > 39 - {{ .Reference.Name }} 40 - </option> 41 - {{ end }} 42 - </optgroup> 43 - <optgroup label="tags" class="bold text-sm"> 44 - {{ range .Tags }} 45 - <option 46 - value="{{ .Reference.Name }}" 47 - class="py-1" 48 - {{ if eq .Reference.Name $.Ref }} 49 - selected 50 - {{ end }} 51 - > 52 - {{ .Reference.Name }} 53 - </option> 54 - {{ else }} 55 - <option class="py-1" disabled>no tags found</option> 56 - {{ end }} 57 - </optgroup> 58 - </select> 59 - <a 60 - href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" 61 - class="ml-2 no-underline flex items-center gap-2 text-sm uppercase font-bold dark:text-white" 62 - > 63 - {{ i "logs" "w-4 h-4" }} 64 - {{ .TotalCommits }} 65 - {{ if eq .TotalCommits 1 }}commit{{ else }}commits{{ end }} 66 - </a> 67 - </div> 41 + <div class="flex justify-between pb-5"> 42 + <select 43 + onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 44 + class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 45 + > 46 + <optgroup label="branches" class="bold text-sm"> 47 + {{ range .Branches }} 48 + <option 49 + value="{{ .Reference.Name }}" 50 + class="py-1" 51 + {{ if eq .Reference.Name $.Ref }} 52 + selected 53 + {{ end }} 54 + > 55 + {{ .Reference.Name }} 56 + </option> 57 + {{ end }} 58 + </optgroup> 59 + <optgroup label="tags" class="bold text-sm"> 60 + {{ range .Tags }} 61 + <option 62 + value="{{ .Reference.Name }}" 63 + class="py-1" 64 + {{ if eq .Reference.Name $.Ref }} 65 + selected 66 + {{ end }} 67 + > 68 + {{ .Reference.Name }} 69 + </option> 70 + {{ else }} 71 + <option class="py-1" disabled>no tags found</option> 72 + {{ end }} 73 + </optgroup> 74 + </select> 75 + <a 76 + href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" 77 + class="ml-2 no-underline flex items-center gap-2 text-sm uppercase font-bold dark:text-white" 78 + > 79 + {{ i "logs" "w-4 h-4" }} 80 + {{ .TotalCommits }} 81 + {{ if eq .TotalCommits 1 }}commit{{ else }}commits{{ end }} 82 + </a> 83 + </div> 68 84 {{ end }} 69 85 70 86 {{ define "fileTree" }} 71 - <div id="file-tree" class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700"> 72 - {{ $containerstyle := "py-1" }} 73 - {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 87 + <div 88 + id="file-tree" 89 + class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" 90 + > 91 + {{ $containerstyle := "py-1" }} 92 + {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 74 93 75 - {{ range .Files }} 76 - {{ if not .IsFile }} 77 - <div class="{{ $containerstyle }}"> 78 - <div class="flex justify-between items-center"> 79 - <a 80 - href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}" 81 - class="{{ $linkstyle }}" 82 - > 83 - <div class="flex items-center gap-2"> 84 - {{ i "folder" "w-3 h-3 fill-current" }} 85 - {{ .Name }} 86 - </div> 87 - </a> 94 + {{ range .Files }} 95 + {{ if not .IsFile }} 96 + <div class="{{ $containerstyle }}"> 97 + <div class="flex justify-between items-center"> 98 + <a 99 + href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}" 100 + class="{{ $linkstyle }}" 101 + > 102 + <div class="flex items-center gap-2"> 103 + {{ i "folder" "w-3 h-3 fill-current" }} 104 + {{ .Name }} 105 + </div> 106 + </a> 88 107 89 - <time class="text-xs text-gray-500 dark:text-gray-400" 90 - >{{ timeFmt .LastCommit.When }}</time 91 - > 108 + <time class="text-xs text-gray-500 dark:text-gray-400" 109 + >{{ timeFmt .LastCommit.When }}</time 110 + > 111 + </div> 92 112 </div> 93 - </div> 113 + {{ end }} 94 114 {{ end }} 95 - {{ end }} 96 115 97 - {{ range .Files }} 98 - {{ if .IsFile }} 99 - <div class="{{ $containerstyle }}"> 100 - <div class="flex justify-between items-center"> 101 - <a 102 - href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}" 103 - class="{{ $linkstyle }}" 104 - > 105 - <div class="flex items-center gap-2"> 106 - {{ i "file" "w-3 h-3" }}{{ .Name }} 107 - </div> 108 - </a> 116 + {{ range .Files }} 117 + {{ if .IsFile }} 118 + <div class="{{ $containerstyle }}"> 119 + <div class="flex justify-between items-center"> 120 + <a 121 + href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}" 122 + class="{{ $linkstyle }}" 123 + > 124 + <div class="flex items-center gap-2"> 125 + {{ i "file" "w-3 h-3" }}{{ .Name }} 126 + </div> 127 + </a> 109 128 110 - <time class="text-xs text-gray-500 dark:text-gray-400" 111 - >{{ timeFmt .LastCommit.When }}</time 112 - > 129 + <time class="text-xs text-gray-500 dark:text-gray-400" 130 + >{{ timeFmt .LastCommit.When }}</time 131 + > 132 + </div> 113 133 </div> 114 - </div> 134 + {{ end }} 115 135 {{ end }} 116 - {{ end }} 117 - </div> 136 + </div> 118 137 {{ end }} 119 138 120 - 121 139 {{ define "commitLog" }} 122 - <div id="commit-log" class="hidden md:block md:col-span-1"> 123 - {{ range .Commits }} 124 - <div class="relative px-2 pb-8"> 125 - <div id="commit-message"> 126 - {{ $messageParts := splitN .Message "\n\n" 2 }} 127 - <div class="text-base cursor-pointer"> 128 - <div> 129 - <div> 130 - <a 131 - href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 132 - class="inline no-underline hover:underline dark:text-white" 133 - >{{ index $messageParts 0 }}</a 134 - > 135 - {{ if gt (len $messageParts) 1 }} 140 + <div id="commit-log" class="hidden md:block md:col-span-1"> 141 + {{ range .Commits }} 142 + <div class="relative px-2 pb-8"> 143 + <div id="commit-message"> 144 + {{ $messageParts := splitN .Message "\n\n" 2 }} 145 + <div class="text-base cursor-pointer"> 146 + <div> 147 + <div> 148 + <a 149 + href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 150 + class="inline no-underline hover:underline dark:text-white" 151 + >{{ index $messageParts 0 }}</a 152 + > 153 + {{ if gt (len $messageParts) 1 }} 136 154 137 - <button 138 - class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 139 - hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')" 140 - > 141 - {{ i "ellipsis" "w-3 h-3" }} 142 - </button> 143 - {{ end }} 144 - </div> 145 - {{ if gt (len $messageParts) 1 }} 146 - <p 147 - class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300" 148 - > 149 - {{ nl2br (unwrapText (index $messageParts 1)) }} 150 - </p> 151 - {{ end }} 152 - </div> 153 - </div> 154 - </div> 155 + <button 156 + class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 157 + hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')" 158 + > 159 + {{ i "ellipsis" "w-3 h-3" }} 160 + </button> 161 + {{ end }} 162 + </div> 163 + {{ if gt (len $messageParts) 1 }} 164 + <p 165 + class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300" 166 + > 167 + {{ nl2br (index $messageParts 1) }} 168 + </p> 169 + {{ end }} 170 + </div> 171 + </div> 172 + </div> 155 173 156 - <div class="text-xs text-gray-500 dark:text-gray-400"> 157 - <span class="font-mono"> 158 - <a 159 - href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 160 - class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 161 - >{{ slice .Hash.String 0 8 }}</a 162 - > 163 - </span> 164 - <span 165 - class="mx-2 before:content-['ยท'] before:select-none" 166 - ></span> 167 - <span> 168 - {{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }} 169 - <a 170 - href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ .Author.Email }}{{ end }}" 171 - class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 172 - >{{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ .Author.Name }}{{ end }}</a 173 - > 174 - </span> 175 - <div 176 - class="inline-block px-1 select-none after:content-['ยท']" 177 - ></div> 178 - <span>{{ timeFmt .Author.When }}</span> 179 - {{ $tagsForCommit := index $.TagMap .Hash.String }} 180 - {{ if gt (len $tagsForCommit) 0 }} 174 + <div class="text-xs text-gray-500 dark:text-gray-400"> 175 + <span class="font-mono"> 176 + <a 177 + href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 178 + class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 179 + >{{ slice .Hash.String 0 8 }}</a></span> 180 + <span 181 + class="mx-2 before:content-['ยท'] before:select-none" 182 + ></span> 183 + <span> 184 + {{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }} 185 + <a 186 + href="{{ if $didOrHandle }} 187 + /{{ $didOrHandle }} 188 + {{ else }} 189 + mailto:{{ .Author.Email }} 190 + {{ end }}" 191 + class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 192 + >{{ if $didOrHandle }} 193 + {{ $didOrHandle }} 194 + {{ else }} 195 + {{ .Author.Name }} 196 + {{ end }}</a 197 + > 198 + </span> 181 199 <div 182 200 class="inline-block px-1 select-none after:content-['ยท']" 183 201 ></div> 184 - {{ end }} 185 - {{ range $tagsForCommit }} 186 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 187 - {{ . }} 188 - </span> 189 - {{ end }} 190 - </div> 191 - </div> 192 - {{ end }} 193 - </div> 202 + <span>{{ timeFmt .Author.When }}</span> 203 + {{ $tagsForCommit := index $.TagMap .Hash.String }} 204 + {{ if gt (len $tagsForCommit) 0 }} 205 + <div 206 + class="inline-block px-1 select-none after:content-['ยท']" 207 + ></div> 208 + {{ end }} 209 + {{ range $tagsForCommit }} 210 + <span 211 + class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center" 212 + > 213 + {{ . }} 214 + </span> 215 + {{ end }} 216 + </div> 217 + </div> 218 + {{ end }} 219 + </div> 194 220 {{ end }} 195 - 196 221 197 222 {{ define "repoAfter" }} 198 223 {{- if .HTMLReadme }} 199 - <section class="mt-4 p-6 rounded bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto {{ if not .Raw }} prose dark:prose-invert dark:[&_pre]:bg-gray-900 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 dark:[&_pre]:border dark:[&_pre]:border-gray-700 {{ end }}"> 200 - <article class="{{ if .Raw }}whitespace-pre{{end}}"> 201 - {{ if .Raw }} 202 - <pre class="dark:bg-gray-900 dark:text-gray-200 dark:border dark:border-gray-700 dark:p-4 dark:rounded">{{ .HTMLReadme }}</pre> 203 - {{ else }} 204 - {{ .HTMLReadme }} 205 - {{ end }} 206 - </article> 207 - </section> 224 + <section 225 + class="mt-4 p-6 rounded bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto {{ if not .Raw }} 226 + prose dark:prose-invert dark:[&_pre]:bg-gray-900 227 + dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 228 + dark:[&_pre]:border dark:[&_pre]:border-gray-700 229 + {{ end }}" 230 + > 231 + <article class="{{ if .Raw }}whitespace-pre{{ end }}"> 232 + {{ if .Raw }} 233 + <pre 234 + class="dark:bg-gray-900 dark:text-gray-200 dark:border dark:border-gray-700 dark:p-4 dark:rounded" 235 + > 236 + {{ .HTMLReadme }}</pre 237 + > 238 + {{ else }} 239 + {{ .HTMLReadme }} 240 + {{ end }} 241 + </article> 242 + </section> 208 243 {{- end -}} 209 244 210 - {{ template "fragments/cloneInstructions" . }} 245 + {{ template "repo/fragments/cloneInstructions" . }} 211 246 {{ end }}
+52
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 + {{ define "repo/issues/fragments/editIssueComment" }} 2 + {{ with .Comment }} 3 + <div id="comment-container-{{.CommentId}}"> 4 + <div class="flex items-center gap-2 mb-2 text-gray-500 text-sm"> 5 + {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 + <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 + 8 + <!-- show user "hats" --> 9 + {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 + {{ if $isIssueAuthor }} 11 + <span class="before:content-['ยท']"></span> 12 + <span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center"> 13 + author 14 + </span> 15 + {{ end }} 16 + 17 + <span class="before:content-['ยท']"></span> 18 + <a 19 + href="#{{ .CommentId }}" 20 + class="text-gray-500 hover:text-gray-500 hover:underline no-underline" 21 + id="{{ .CommentId }}"> 22 + {{ .Created | timeFmt }} 23 + </a> 24 + 25 + <button 26 + class="btn px-2 py-1 flex items-center gap-2 text-sm" 27 + hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 28 + hx-include="#edit-textarea-{{ .CommentId }}" 29 + hx-target="#comment-container-{{ .CommentId }}" 30 + hx-swap="outerHTML"> 31 + {{ i "check" "w-4 h-4" }} 32 + </button> 33 + <button 34 + class="btn px-2 py-1 flex items-center gap-2 text-sm" 35 + hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 36 + hx-target="#comment-container-{{ .CommentId }}" 37 + hx-swap="outerHTML"> 38 + {{ i "x" "w-4 h-4" }} 39 + </button> 40 + <span id="comment-{{.CommentId}}-status"></span> 41 + </div> 42 + 43 + <div> 44 + <textarea 45 + id="edit-textarea-{{ .CommentId }}" 46 + name="body" 47 + class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea> 48 + </div> 49 + </div> 50 + {{ end }} 51 + {{ end }} 52 +
+59
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 1 + {{ define "repo/issues/fragments/issueComment" }} 2 + {{ with .Comment }} 3 + <div id="comment-container-{{.CommentId}}"> 4 + <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm"> 5 + {{ $owner := index $.DidHandleMap .OwnerDid }} 6 + <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 + 8 + <span class="before:content-['ยท']"></span> 9 + <a 10 + href="#{{ .CommentId }}" 11 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 12 + id="{{ .CommentId }}"> 13 + {{ if .Deleted }} 14 + deleted {{ .Deleted | timeFmt }} 15 + {{ else if .Edited }} 16 + edited {{ .Edited | timeFmt }} 17 + {{ else }} 18 + {{ .Created | timeFmt }} 19 + {{ end }} 20 + </a> 21 + 22 + <!-- show user "hats" --> 23 + {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 24 + {{ if $isIssueAuthor }} 25 + <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 26 + author 27 + </span> 28 + {{ end }} 29 + 30 + {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 31 + {{ if and $isCommentOwner (not .Deleted) }} 32 + <button 33 + class="btn px-2 py-1 text-sm" 34 + hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 35 + hx-swap="outerHTML" 36 + hx-target="#comment-container-{{.CommentId}}" 37 + > 38 + {{ i "pencil" "w-4 h-4" }} 39 + </button> 40 + <button 41 + class="btn px-2 py-1 text-sm text-red-500" 42 + hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 43 + hx-confirm="Are you sure you want to delete your comment?" 44 + hx-swap="outerHTML" 45 + hx-target="#comment-container-{{.CommentId}}" 46 + > 47 + {{ i "trash-2" "w-4 h-4" }} 48 + </button> 49 + {{ end }} 50 + 51 + </div> 52 + {{ if not .Deleted }} 53 + <div class="prose dark:prose-invert"> 54 + {{ .Body | markdown }} 55 + </div> 56 + {{ end }} 57 + </div> 58 + {{ end }} 59 + {{ end }}
+112 -42
appview/pages/templates/repo/issues/issue.html
··· 44 44 {{ end }} 45 45 46 46 {{ define "repoAfter" }} 47 - {{ if gt (len .Comments) 0 }} 48 - <section id="comments" class="mt-8 space-y-4 relative"> 47 + <section id="comments" class="my-2 mt-2 space-y-2 relative"> 49 48 {{ range $index, $comment := .Comments }} 50 49 <div 51 50 id="comment-{{ .CommentId }}" 52 - class="rounded bg-white px-6 py-4 relative dark:bg-gray-800"> 53 - {{ if eq $index 0 }} 54 - <div class="absolute left-8 -top-8 w-px h-8 bg-gray-300 dark:bg-gray-700" ></div> 55 - {{ else }} 56 - <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-700" ></div> 51 + class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 52 + {{ if gt $index 0 }} 53 + <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 57 54 {{ end }} 58 - 59 - {{ template "fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}} 55 + {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}} 60 56 </div> 61 57 {{ end }} 62 58 </section> 63 - {{ end }} 64 59 65 60 {{ block "newComment" . }} {{ end }} 66 61 67 - {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }} 68 - {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 69 - {{ if or $isIssueAuthor $isRepoCollaborator }} 70 - {{ $action := "close" }} 71 - {{ $icon := "circle-x" }} 72 - {{ $hoverColor := "red" }} 73 - {{ if eq .State "closed" }} 74 - {{ $action = "reopen" }} 75 - {{ $icon = "circle-dot" }} 76 - {{ $hoverColor = "green" }} 77 - {{ end }} 78 - <form 79 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/{{ $action }}" 80 - hx-swap="none" 81 - class="mt-8" 82 - > 83 - <button type="submit" class="btn hover:bg-{{ $hoverColor }}-300"> 84 - {{ i $icon "w-4 h-4 mr-2" }} 85 - <span class="text-black dark:text-gray-400">{{ $action }}</span> 86 - </button> 87 - <div id="issue-action" class="error"></div> 88 - </form> 89 - {{ end }} 90 62 {{ end }} 91 63 92 64 {{ define "newComment" }} 93 65 {{ if .LoggedInUser }} 94 - <div class="bg-white rounded drop-shadow-sm py-4 px-6 relative w-full flex flex-col gap-2 mt-8 dark:bg-gray-800 dark:text-gray-400"> 95 - <div class="absolute left-8 -top-8 w-px h-8 bg-gray-300 dark:bg-gray-700" ></div> 96 - <div class="text-sm text-gray-500 dark:text-gray-400"> 66 + <form 67 + id="comment-form" 68 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 69 + hx-on::after-request="if(event.detail.successful) this.reset()" 70 + > 71 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5"> 72 + <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 97 73 {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 98 74 </div> 99 - <form hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"> 100 75 <textarea 76 + id="comment-textarea" 101 77 name="body" 102 78 class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 103 - placeholder="Add to the discussion..." 79 + placeholder="Add to the discussion. Markdown is supported." 80 + onkeyup="updateCommentForm()" 104 81 ></textarea> 105 - <button type="submit" class="btn mt-2">comment</button> 106 82 <div id="issue-comment"></div> 107 - </form> 83 + <div id="issue-action" class="error"></div> 108 84 </div> 85 + 86 + <div class="flex gap-2 mt-2"> 87 + <button 88 + id="comment-button" 89 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 90 + type="submit" 91 + hx-disabled-elt="#comment-button" 92 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline" 93 + disabled 94 + > 95 + {{ i "message-square-plus" "w-4 h-4" }} 96 + comment 97 + </button> 98 + 99 + {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }} 100 + {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 101 + {{ if and (or $isIssueAuthor $isRepoCollaborator) (eq .State "open") }} 102 + <button 103 + id="close-button" 104 + type="button" 105 + class="btn flex items-center gap-2" 106 + hx-trigger="click" 107 + > 108 + {{ i "ban" "w-4 h-4" }} 109 + close 110 + </button> 111 + <div 112 + id="close-with-comment" 113 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 114 + hx-trigger="click from:#close-button" 115 + hx-disabled-elt="#close-with-comment" 116 + hx-target="#issue-comment" 117 + hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 118 + hx-swap="none" 119 + > 120 + </div> 121 + <div 122 + id="close-issue" 123 + hx-disabled-elt="#close-issue" 124 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 125 + hx-trigger="click from:#close-button" 126 + hx-target="#issue-action" 127 + hx-swap="none" 128 + > 129 + </div> 130 + <script> 131 + document.addEventListener('htmx:configRequest', function(evt) { 132 + if (evt.target.id === 'close-with-comment') { 133 + const commentText = document.getElementById('comment-textarea').value.trim(); 134 + if (commentText === '') { 135 + evt.detail.parameters = {}; 136 + evt.preventDefault(); 137 + } 138 + } 139 + }); 140 + </script> 141 + {{ else if and (or $isIssueAuthor $isRepoCollaborator) (eq .State "closed") }} 142 + <button 143 + type="button" 144 + class="btn flex items-center gap-2" 145 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 146 + hx-swap="none" 147 + > 148 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 149 + reopen 150 + </button> 151 + {{ end }} 152 + 153 + <script> 154 + function updateCommentForm() { 155 + const textarea = document.getElementById('comment-textarea'); 156 + const commentButton = document.getElementById('comment-button'); 157 + const closeButton = document.getElementById('close-button'); 158 + 159 + if (textarea.value.trim() !== '') { 160 + commentButton.removeAttribute('disabled'); 161 + } else { 162 + commentButton.setAttribute('disabled', ''); 163 + } 164 + 165 + if (closeButton) { 166 + if (textarea.value.trim() !== '') { 167 + closeButton.innerHTML = '{{ i "ban" "w-4 h-4" }} close with comment'; 168 + } else { 169 + closeButton.innerHTML = '{{ i "ban" "w-4 h-4" }} close'; 170 + } 171 + } 172 + } 173 + 174 + document.addEventListener('DOMContentLoaded', function() { 175 + updateCommentForm(); 176 + }); 177 + </script> 178 + </div> 179 + </form> 109 180 {{ else }} 110 - <div class="bg-white dark:bg-gray-800 dark:text-gray-400 rounded drop-shadow-sm px-6 py-4 mt-8"> 111 - <div class="absolute left-8 -top-8 w-px h-8 bg-gray-300 dark:bg-gray-700" ></div> 181 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 112 182 <a href="/login" class="underline">login</a> to join the discussion 113 183 </div> 114 184 {{ end }}
+41 -3
appview/pages/templates/repo/issues/issues.html
··· 4 4 <div class="flex justify-between items-center"> 5 5 <p> 6 6 filtering 7 - <select class="border px-1 bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700" onchange="window.location.href = '/{{ .RepoInfo.FullName }}/issues?state=' + this.value"> 7 + <select class="border p-1 bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700" onchange="window.location.href = '/{{ .RepoInfo.FullName }}/issues?state=' + this.value"> 8 8 <option value="open" {{ if .FilteringByOpen }}selected{{ end }}>open ({{ .RepoInfo.Stats.IssueCount.Open }})</option> 9 9 <option value="closed" {{ if not .FilteringByOpen }}selected{{ end }}>closed ({{ .RepoInfo.Stats.IssueCount.Closed }})</option> 10 10 </select> ··· 13 13 <a 14 14 href="/{{ .RepoInfo.FullName }}/issues/new" 15 15 class="btn text-sm flex items-center gap-2 no-underline hover:no-underline"> 16 - {{ i "plus" "w-4 h-4" }} 17 - <span>new issue</span> 16 + {{ i "circle-plus" "w-4 h-4" }} 17 + <span>new</span> 18 18 </a> 19 19 </div> 20 20 <div class="error" id="issues"></div> ··· 69 69 </p> 70 70 </div> 71 71 {{ end }} 72 + </div> 73 + 74 + {{ block "pagination" . }} {{ end }} 75 + 76 + {{ end }} 77 + 78 + {{ define "pagination" }} 79 + <div class="flex justify-end mt-4 gap-2"> 80 + {{ $currentState := "closed" }} 81 + {{ if .FilteringByOpen }} 82 + {{ $currentState = "open" }} 83 + {{ end }} 84 + 85 + {{ if gt .Page.Offset 0 }} 86 + {{ $prev := .Page.Previous }} 87 + <a 88 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 89 + hx-boost="true" 90 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 91 + > 92 + {{ i "chevron-left" "w-4 h-4" }} 93 + previous 94 + </a> 95 + {{ else }} 96 + <div></div> 97 + {{ end }} 98 + 99 + {{ if eq (len .Issues) .Page.Limit }} 100 + {{ $next := .Page.Next }} 101 + <a 102 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 103 + hx-boost="true" 104 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 105 + > 106 + next 107 + {{ i "chevron-right" "w-4 h-4" }} 108 + </a> 109 + {{ end }} 72 110 </div> 73 111 {{ end }}
+1 -1
appview/pages/templates/repo/log.html
··· 83 83 <p 84 84 class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300" 85 85 > 86 - {{ nl2br (unwrapText (index $messageParts 1)) }} 86 + {{ nl2br (index $messageParts 1) }} 87 87 </p> 88 88 {{ end }} 89 89 </div>
+90
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 1 + {{ define "repo/pulls/fragments/pullActions" }} 2 + {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 3 + {{ $roundNumber := .RoundNumber }} 4 + 5 + {{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }} 6 + {{ $isMerged := .Pull.State.IsMerged }} 7 + {{ $isClosed := .Pull.State.IsClosed }} 8 + {{ $isOpen := .Pull.State.IsOpen }} 9 + {{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }} 10 + {{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }} 11 + {{ $isLastRound := eq $roundNumber $lastIdx }} 12 + {{ $isSameRepoBranch := .Pull.IsBranchBased }} 13 + {{ $isUpToDate := .ResubmitCheck.No }} 14 + <div class="relative w-fit"> 15 + <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2"> 16 + <button 17 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 18 + hx-target="#actions-{{$roundNumber}}" 19 + hx-swap="outerHtml" 20 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline"> 21 + {{ i "message-square-plus" "w-4 h-4" }} 22 + <span>comment</span> 23 + </button> 24 + {{ if and $isPushAllowed $isOpen $isLastRound }} 25 + {{ $disabled := "" }} 26 + {{ if $isConflicted }} 27 + {{ $disabled = "disabled" }} 28 + {{ end }} 29 + <button 30 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 31 + hx-swap="none" 32 + hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 33 + class="btn p-2 flex items-center gap-2" {{ $disabled }}> 34 + {{ i "git-merge" "w-4 h-4" }} 35 + <span>merge</span> 36 + </button> 37 + {{ end }} 38 + 39 + {{ if and $isPullAuthor $isOpen $isLastRound }} 40 + {{ $disabled := "" }} 41 + {{ if $isUpToDate }} 42 + {{ $disabled = "disabled" }} 43 + {{ end }} 44 + <button id="resubmitBtn" 45 + {{ if not .Pull.IsPatchBased }} 46 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 47 + {{ else }} 48 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 49 + hx-target="#actions-{{$roundNumber}}" 50 + hx-swap="outerHtml" 51 + {{ end }} 52 + 53 + hx-disabled-elt="#resubmitBtn" 54 + class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" {{ $disabled }} 55 + 56 + {{ if $disabled }} 57 + title="Update this branch to resubmit this pull request" 58 + {{ else }} 59 + title="Resubmit this pull request" 60 + {{ end }} 61 + > 62 + {{ i "rotate-ccw" "w-4 h-4" }} 63 + <span>resubmit</span> 64 + </button> 65 + {{ end }} 66 + 67 + {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 68 + <button 69 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 70 + hx-swap="none" 71 + class="btn p-2 flex items-center gap-2"> 72 + {{ i "ban" "w-4 h-4" }} 73 + <span>close</span> 74 + </button> 75 + {{ end }} 76 + 77 + {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 78 + <button 79 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 80 + hx-swap="none" 81 + class="btn p-2 flex items-center gap-2"> 82 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 83 + <span>reopen</span> 84 + </button> 85 + {{ end }} 86 + </div> 87 + </div> 88 + {{ end }} 89 + 90 +
+25
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
··· 1 + {{ define "repo/pulls/fragments/pullCompareBranches" }} 2 + <div id="patch-upload"> 3 + <label for="targetBranch" class="dark:text-white" 4 + >select a branch</label 5 + > 6 + <div class="flex flex-wrap gap-2 items-center"> 7 + <select 8 + name="sourceBranch" 9 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 10 + > 11 + <option disabled selected>source branch</option> 12 + {{ range .Branches }} 13 + <option value="{{ .Reference.Name }}" class="py-1"> 14 + {{ .Reference.Name }} 15 + </option> 16 + {{ end }} 17 + </select> 18 + </div> 19 + </div> 20 + 21 + <p class="mt-4"> 22 + Title and description are optional; if left out, they will be extracted 23 + from the first commit. 24 + </p> 25 + {{ end }}
+46
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
··· 1 + {{ define "repo/pulls/fragments/pullCompareForks" }} 2 + <div id="patch-upload"> 3 + <label for="forkSelect" class="dark:text-white" 4 + >select a fork to compare</label 5 + > 6 + <div class="flex flex-wrap gap-4 items-center mb-4"> 7 + <div class="flex flex-wrap gap-2 items-center"> 8 + <select 9 + id="forkSelect" 10 + name="fork" 11 + required 12 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 13 + hx-get="/{{ $.RepoInfo.FullName }}/pulls/new/fork-branches" 14 + hx-target="#branch-selection" 15 + hx-vals='{"fork": this.value}' 16 + hx-swap="innerHTML" 17 + onchange="document.getElementById('hiddenForkInput').value = this.value;" 18 + > 19 + <option disabled selected>select a fork</option> 20 + {{ range .Forks }} 21 + <option value="{{ .Name }}" class="py-1"> 22 + {{ .Name }} 23 + </option> 24 + {{ end }} 25 + </select> 26 + 27 + <input 28 + type="hidden" 29 + id="hiddenForkInput" 30 + name="fork" 31 + value="" 32 + /> 33 + </div> 34 + 35 + <div id="branch-selection"> 36 + <div class="text-sm text-gray-500 dark:text-gray-400"> 37 + Select a fork first to view available branches 38 + </div> 39 + </div> 40 + </div> 41 + </div> 42 + <p class="mt-4"> 43 + Title and description are optional; if left out, they will be extracted 44 + from the first commit. 45 + </p> 46 + {{ end }}
+15
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
··· 1 + {{ define "repo/pulls/fragments/pullCompareForksBranches" }} 2 + <div class="flex flex-wrap gap-2 items-center"> 3 + <select 4 + name="sourceBranch" 5 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 6 + > 7 + <option disabled selected>source branch</option> 8 + {{ range .SourceBranches }} 9 + <option value="{{ .Reference.Name }}" class="py-1"> 10 + {{ .Reference.Name }} 11 + </option> 12 + {{ end }} 13 + </select> 14 + </div> 15 + {{ end }}
+70
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 1 + {{ define "repo/pulls/fragments/pullHeader" }} 2 + <header class="pb-4"> 3 + <h1 class="text-2xl dark:text-white"> 4 + {{ .Pull.Title }} 5 + <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 + </h1> 7 + </header> 8 + 9 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 10 + {{ $icon := "ban" }} 11 + 12 + {{ if .Pull.State.IsOpen }} 13 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 14 + {{ $icon = "git-pull-request" }} 15 + {{ else if .Pull.State.IsMerged }} 16 + {{ $bgColor = "bg-purple-600 dark:bg-purple-700" }} 17 + {{ $icon = "git-merge" }} 18 + {{ end }} 19 + 20 + <section class="mt-2"> 21 + <div class="flex items-center gap-2"> 22 + <div 23 + id="state" 24 + class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}" 25 + > 26 + {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 27 + <span class="text-white">{{ .Pull.State.String }}</span> 28 + </div> 29 + <span class="text-gray-500 dark:text-gray-400 text-sm"> 30 + opened by 31 + {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 32 + <a href="/{{ $owner }}" class="no-underline hover:underline" 33 + >{{ $owner }}</a 34 + > 35 + <span class="select-none before:content-['\00B7']"></span> 36 + <time>{{ .Pull.Created | timeFmt }}</time> 37 + <span class="select-none before:content-['\00B7']"></span> 38 + <span> 39 + targeting 40 + <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 41 + <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Pull.TargetBranch }}" class="no-underline hover:underline">{{ .Pull.TargetBranch }}</a> 42 + </span> 43 + </span> 44 + {{ if not .Pull.IsPatchBased }} 45 + <span>from 46 + {{ if .Pull.IsForkBased }} 47 + {{ if .Pull.PullSource.Repo }} 48 + <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a> 49 + {{ else }} 50 + <span class="italic">[deleted fork]</span> 51 + {{ end }} 52 + {{ end }} 53 + 54 + <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 55 + {{ .Pull.PullSource.Branch }} 56 + </span> 57 + </span> 58 + {{ end }} 59 + </span> 60 + </div> 61 + 62 + {{ if .Pull.Body }} 63 + <article id="body" class="mt-8 prose dark:prose-invert"> 64 + {{ .Pull.Body | markdown }} 65 + </article> 66 + {{ end }} 67 + </section> 68 + 69 + 70 + {{ end }}
+32
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 1 + {{ define "repo/pulls/fragments/pullNewComment" }} 2 + <div 3 + id="pull-comment-card-{{ .RoundNumber }}" 4 + class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 + <div class="text-sm text-gray-500 dark:text-gray-400"> 6 + {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 7 + </div> 8 + <form 9 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 10 + hx-swap="none" 11 + class="w-full flex flex-wrap gap-2"> 12 + <textarea 13 + name="body" 14 + class="w-full p-2 rounded border border-gray-200" 15 + placeholder="Add to the discussion..."></textarea> 16 + <button type="submit" class="btn flex items-center gap-2"> 17 + {{ i "message-square" "w-4 h-4" }} comment 18 + </button> 19 + <button 20 + type="button" 21 + class="btn flex items-center gap-2" 22 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 23 + hx-swap="outerHTML" 24 + hx-target="#pull-comment-card-{{ .RoundNumber }}"> 25 + {{ i "x" "w-4 h-4" }} 26 + <span>cancel</span> 27 + </button> 28 + <div id="pull-comment"></div> 29 + </form> 30 + </div> 31 + {{ end }} 32 +
+21
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
··· 1 + {{ define "repo/pulls/fragments/pullPatchUpload" }} 2 + <div id="patch-upload"> 3 + <p> 4 + You can paste a <code>git diff</code> or a 5 + <code>git format-patch</code> patch series here. 6 + </p> 7 + <textarea 8 + hx-trigger="keyup changed delay:500ms, paste delay:500ms" 9 + hx-post="/{{ .RepoInfo.FullName }}/pulls/new/validate-patch" 10 + hx-swap="none" 11 + name="patch" 12 + id="patch" 13 + rows="12" 14 + class="w-full mt-2 resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600" 15 + placeholder="diff --git a/file.txt b/file.txt 16 + index 1234567..abcdefg 100644 17 + --- a/file.txt 18 + +++ b/file.txt" 19 + ></textarea> 20 + </div> 21 + {{ end }}
+52
appview/pages/templates/repo/pulls/fragments/pullResubmit.html
··· 1 + {{ define "repo/pulls/fragments/pullResubmit" }} 2 + <div 3 + id="resubmit-pull-card" 4 + class="rounded relative border bg-amber-50 dark:bg-amber-900 border-amber-200 dark:border-amber-500 px-6 py-2"> 5 + 6 + <div class="flex items-center gap-2 text-amber-500 dark:text-amber-50"> 7 + {{ i "pencil" "w-4 h-4" }} 8 + <span class="font-medium">resubmit your patch</span> 9 + </div> 10 + 11 + <div class="mt-2 text-sm text-gray-700 dark:text-gray-200"> 12 + You can update this patch to address any reviews. 13 + This will begin a new round of reviews, 14 + but you'll still be able to view your previous submissions and feedback. 15 + </div> 16 + 17 + <div class="mt-4 flex flex-col"> 18 + <form 19 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 20 + hx-swap="none" 21 + class="w-full flex flex-wrap gap-2"> 22 + <textarea 23 + name="patch" 24 + class="w-full p-2 mb-2" 25 + placeholder="Paste your updated patch here." 26 + rows="15" 27 + >{{.Pull.LatestPatch}}</textarea> 28 + <button 29 + type="submit" 30 + class="btn flex items-center gap-2" 31 + {{ if or .Pull.State.IsClosed }} 32 + disabled 33 + {{ end }}> 34 + {{ i "rotate-ccw" "w-4 h-4" }} 35 + <span>resubmit</span> 36 + </button> 37 + <button 38 + type="button" 39 + class="btn flex items-center gap-2" 40 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions" 41 + hx-swap="outerHTML" 42 + hx-target="#resubmit-pull-card"> 43 + {{ i "x" "w-4 h-4" }} 44 + <span>cancel</span> 45 + </button> 46 + </form> 47 + 48 + <div id="resubmit-error" class="error"></div> 49 + <div id="resubmit-success" class="success"></div> 50 + </div> 51 + </div> 52 + {{ end }}
+25
appview/pages/templates/repo/pulls/interdiff.html
··· 1 + {{ define "title" }} 2 + interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} 3 + {{ end }} 4 + 5 + {{ define "content" }} 6 + <section class="rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white"> 7 + <header class="pb-2"> 8 + <div class="flex gap-3 items-center mb-3"> 9 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium"> 10 + {{ i "arrow-left" "w-5 h-5" }} 11 + back 12 + </a> 13 + <span class="select-none before:content-['\00B7']"></span> 14 + interdiff of round #{{ .Round }} and #{{ sub .Round 1 }} 15 + </div> 16 + <div class="border-t border-gray-200 dark:border-gray-700 my-2"></div> 17 + {{ template "repo/pulls/fragments/pullHeader" . }} 18 + </header> 19 + </section> 20 + 21 + <section> 22 + {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }} 23 + </section> 24 + {{ end }} 25 +
+90 -81
appview/pages/templates/repo/pulls/new.html
··· 7 7 hx-swap="none" 8 8 > 9 9 <div class="flex flex-col gap-4"> 10 - <div> 11 - <label for="title" class="dark:text-white">write a title</label> 12 - <input type="text" name="title" id="title" class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" /> 13 - </div> 10 + <label>configure your pull request</label> 14 11 15 - <div> 16 - <label for="body" class="dark:text-white">add a description</label> 17 - <textarea 18 - name="body" 19 - id="body" 20 - rows="6" 21 - class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600" 22 - placeholder="Describe your change. Markdown is supported." 23 - ></textarea> 24 - </div> 12 + <p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p> 13 + <div class="pb-2"> 14 + <select 15 + required 16 + name="targetBranch" 17 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 18 + > 19 + <option disabled selected>target branch</option> 20 + {{ range .Branches }} 21 + <option value="{{ .Reference.Name }}" class="py-1"> 22 + {{ .Reference.Name }} 23 + </option> 24 + {{ end }} 25 + </select> 26 + </div> 25 27 28 + <p>Next, choose a pull strategy.</p> 29 + <nav class="flex space-x-4 items-end"> 30 + <button 31 + type="button" 32 + class="px-3 py-2 pb-2 btn" 33 + hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload" 34 + hx-target="#patch-strategy" 35 + hx-swap="innerHTML" 36 + > 37 + paste patch 38 + </button> 26 39 27 - <label>configure your pull request</label> 40 + {{ if .RepoInfo.Roles.IsPushAllowed }} 41 + <span class="text-sm text-gray-500 dark:text-gray-400 pb-2"> 42 + or 43 + </span> 44 + <button 45 + type="button" 46 + class="px-3 py-2 pb-2 btn" 47 + hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches" 48 + hx-target="#patch-strategy" 49 + hx-swap="innerHTML" 50 + > 51 + compare branches 52 + </button> 53 + {{ end }} 28 54 29 - <p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p> 30 - <div class="pb-2"> 31 - <select 32 - required 33 - name="targetBranch" 34 - class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 35 - > 36 - <option disabled selected>target branch</option> 37 - {{ range .Branches }} 38 - <option value="{{ .Reference.Name }}" class="py-1"> 39 - {{ .Reference.Name }} 40 - </option> 41 - {{ end }} 42 - </select> 43 - </div> 55 + 56 + <span class="text-sm text-gray-500 dark:text-gray-400 pb-2"> 57 + or 58 + </span> 59 + <button 60 + type="button" 61 + class="px-3 py-2 pb-2 btn" 62 + hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks" 63 + hx-target="#patch-strategy" 64 + hx-swap="innerHTML" 65 + > 66 + compare forks 67 + </button> 68 + </nav> 69 + 70 + <section id="patch-strategy"> 71 + {{ template "repo/pulls/fragments/pullPatchUpload" . }} 72 + </section> 73 + 74 + <p id="patch-preview"></p> 44 75 45 - <p>Then, choose a pull strategy.</p> 46 - <nav class="flex space-x-4 items-end"> 47 - <button 48 - type="button" 49 - class="px-3 py-2 pb-2 btn" 50 - hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload" 51 - hx-target="#patch-strategy" 52 - hx-swap="innerHTML" 53 - > 54 - paste patch 55 - </button> 76 + <div id="patch-error" class="error dark:text-red-300"></div> 56 77 57 - {{ if .RepoInfo.Roles.IsPushAllowed }} 58 - <span class="text-sm text-gray-500 dark:text-gray-400 pb-2"> 59 - or 60 - </span> 61 - <button 62 - type="button" 63 - class="px-3 py-2 pb-2 btn" 64 - hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches" 65 - hx-target="#patch-strategy" 66 - hx-swap="innerHTML" 67 - > 68 - compare branches 69 - </button> 70 - {{ end }} 78 + <div> 79 + <label for="title" class="dark:text-white">write a title</label> 71 80 72 - <span class="text-sm text-gray-500 dark:text-gray-400 pb-2"> 73 - or 74 - </span> 75 - <button 76 - type="button" 77 - class="px-3 py-2 pb-2 btn" 78 - hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks" 79 - hx-target="#patch-strategy" 80 - hx-swap="innerHTML" 81 - > 82 - compare forks 83 - </button> 84 - </nav> 81 + <input 82 + type="text" 83 + name="title" 84 + id="title" 85 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" 86 + placeholder="One-line summary of your change." 87 + /> 88 + </div> 85 89 86 - <section id="patch-strategy"> 87 - {{ template "fragments/pullPatchUpload" . }} 88 - </section> 90 + <div> 91 + <label for="body" class="dark:text-white" 92 + >add a description</label 93 + > 89 94 90 - <div class="flex justify-start items-center gap-2 mt-4"> 91 - <button type="submit" class="btn flex items-center gap-2"> 92 - {{ i "git-pull-request-create" "w-4 h-4" }} 93 - create pull 94 - </button> 95 - </div> 95 + <textarea 96 + name="body" 97 + id="body" 98 + rows="6" 99 + class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600" 100 + placeholder="Describe your change. Markdown is supported." 101 + ></textarea> 102 + </div> 96 103 104 + <div class="flex justify-start items-center gap-2 mt-4"> 105 + <button type="submit" class="btn flex items-center gap-2"> 106 + {{ i "git-pull-request-create" "w-4 h-4" }} 107 + create pull 108 + </button> 109 + </div> 97 110 </div> 98 111 <div id="pull" class="error dark:text-red-300"></div> 99 112 </form> 100 113 {{ end }} 101 - 102 - {{ define "repoAfter" }} 103 - <div id="patch-preview" class="error dark:text-red-300"></div> 104 - {{ end }}
+21 -84
appview/pages/templates/repo/pulls/patch.html
··· 3 3 {{ end }} 4 4 5 5 {{ define "content" }} 6 - {{ $stat := .Diff.Stat }} 7 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white"> 8 - <header class="pb-2"> 9 - <div class="flex gap-3 items-center mb-3"> 10 - <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium"> 11 - {{ i "arrow-left" "w-5 h-5" }} 12 - back 13 - </a> 14 - <span class="select-none before:content-['\00B7']"></span> 15 - round #{{ .Round }} 16 - <span class="select-none before:content-['\00B7']"></span> 17 - <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Round }}.patch"> 18 - view raw 19 - </a> 20 - </div> 21 - <div class="border-t border-gray-200 dark:border-gray-700 my-2"></div> 22 - <h1 class="text-2xl mt-3"> 23 - {{ .Pull.Title }} 24 - <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 25 - </h1> 26 - </header> 27 - 28 - {{ $bgColor := "bg-gray-800" }} 29 - {{ $icon := "ban" }} 30 - 31 - {{ if .Pull.State.IsOpen }} 32 - {{ $bgColor = "bg-green-600" }} 33 - {{ $icon = "git-pull-request" }} 34 - {{ else if .Pull.State.IsMerged }} 35 - {{ $bgColor = "bg-purple-600" }} 36 - {{ $icon = "git-merge" }} 37 - {{ end }} 38 - 39 - <section> 40 - <div class="flex items-center gap-2"> 41 - <div 42 - id="state" 43 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}" 44 - > 45 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 46 - <span class="text-white">{{ .Pull.State.String }}</span> 47 - </div> 48 - <span class="text-gray-500 dark:text-gray-400 text-sm"> 49 - opened by 50 - {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 51 - <a href="/{{ $owner }}" class="no-underline hover:underline" 52 - >{{ $owner }}</a 53 - > 54 - <span class="select-none before:content-['\00B7']"></span> 55 - <time>{{ .Pull.Created | timeFmt }}</time> 56 - <span class="select-none before:content-['\00B7']"></span> 57 - <span>targeting branch 58 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 59 - {{ .Pull.TargetBranch }} 60 - </span> 61 - </span> 62 - </span> 63 - </div> 64 - 65 - {{ if .Pull.Body }} 66 - <article id="body" class="mt-2 prose dark:prose-invert"> 67 - {{ .Pull.Body | markdown }} 68 - </article> 69 - {{ end }} 70 - </section> 71 - 72 - <div id="diff-stat"> 73 - <br> 74 - <strong class="text-sm uppercase mb-4">Changed files</strong> 75 - {{ range .Diff.Diff }} 76 - <ul> 77 - {{ if .IsDelete }} 78 - <li><a href="#file-{{ .Name.Old }}">{{ .Name.Old }}</a></li> 79 - {{ else }} 80 - <li><a href="#file-{{ .Name.New }}">{{ .Name.New }}</a></li> 81 - {{ end }} 82 - </ul> 83 - {{ end }} 84 - </div> 85 - </div> 86 - 87 - <section> 88 - {{ template "fragments/diff" (list .RepoInfo.FullName .Diff) }} 89 - </section> 6 + <section> 7 + <section 8 + class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white" 9 + > 10 + <div class="flex gap-3 items-center mb-3"> 11 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium"> 12 + {{ i "arrow-left" "w-5 h-5" }} 13 + back 14 + </a> 15 + <span class="select-none before:content-['\00B7']"></span> 16 + round<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .Round }}</span> 17 + <span class="select-none before:content-['\00B7']"></span> 18 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Round }}.patch"> 19 + view raw 20 + </a> 21 + </div> 22 + <div class="border-t border-gray-200 dark:border-gray-700 my-2"></div> 23 + {{ template "repo/pulls/fragments/pullHeader" . }} 24 + </section> 25 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 26 + </section> 90 27 {{ end }}
+83 -84
appview/pages/templates/repo/pulls/pull.html
··· 3 3 {{ end }} 4 4 5 5 {{ define "repoContent" }} 6 - <header class="pb-4"> 7 - <h1 class="text-2xl dark:text-white"> 8 - {{ .Pull.Title }} 9 - <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 10 - </h1> 11 - </header> 12 - 13 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 14 - {{ $icon := "ban" }} 15 - 16 - {{ if .Pull.State.IsOpen }} 17 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 18 - {{ $icon = "git-pull-request" }} 19 - {{ else if .Pull.State.IsMerged }} 20 - {{ $bgColor = "bg-purple-600 dark:bg-purple-700" }} 21 - {{ $icon = "git-merge" }} 22 - {{ end }} 23 - 24 - <section> 25 - <div class="flex items-center gap-2"> 26 - <div 27 - id="state" 28 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}" 29 - > 30 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 31 - <span class="text-white">{{ .Pull.State.String }}</span> 32 - </div> 33 - <span class="text-gray-500 dark:text-gray-400 text-sm"> 34 - opened by 35 - {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 36 - <a href="/{{ $owner }}" class="no-underline hover:underline" 37 - >{{ $owner }}</a 38 - > 39 - <span class="select-none before:content-['\00B7']"></span> 40 - <time>{{ .Pull.Created | timeFmt }}</time> 41 - <span class="select-none before:content-['\00B7']"></span> 42 - <span> 43 - targeting 44 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 45 - <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Pull.TargetBranch }}" class="no-underline hover:underline">{{ .Pull.TargetBranch }}</a> 46 - </span> 47 - </span> 48 - {{ if not .Pull.IsPatch }} 49 - <span>from 50 - {{ if not .Pull.IsSameRepoBranch }} 51 - <a href="/{{ $owner }}/{{ .PullSourceRepo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSourceRepo.Name }}</a> 52 - {{ end }} 53 - 54 - {{ $fullRepo := .RepoInfo.FullName }} 55 - {{ if not .Pull.IsSameRepoBranch }} 56 - {{ $fullRepo = printf "%s/%s" $owner .PullSourceRepo.Name }} 57 - {{ end }} 58 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 59 - <a href="/{{ $fullRepo }}/tree/{{ .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 60 - </span> 61 - </span> 62 - {{ end }} 63 - </span> 64 - </div> 65 - 66 - {{ if .Pull.Body }} 67 - <article id="body" class="mt-2 prose dark:prose-invert"> 68 - {{ .Pull.Body | markdown }} 69 - </article> 70 - {{ end }} 71 - </section> 72 - 6 + {{ template "repo/pulls/fragments/pullHeader" . }} 73 7 {{ end }} 74 8 75 9 {{ define "repoAfter" }} ··· 88 22 {{ $targetBranch := .Pull.TargetBranch }} 89 23 {{ $repoName := .RepoInfo.FullName }} 90 24 {{ range $idx, $item := .Pull.Submissions }} 91 - {{ $diff := $item.AsNiceDiff $targetBranch }} 92 25 {{ with $item }} 93 26 <details {{ if eq $idx $lastIdx }}open{{ end }}> 94 27 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 95 28 <div class="flex flex-wrap gap-2 items-center"> 96 29 <!-- round number --> 97 30 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 98 - #{{ .RoundNumber }} 31 + <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 99 32 </div> 100 33 <!-- round summary --> 101 34 <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> ··· 117 50 {{ len .Comments }} comment{{$s}} 118 51 </span> 119 52 </div> 120 - <!-- view patch --> 53 + 121 54 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2" 122 55 hx-boost="true" 123 56 href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 124 57 {{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span> 125 58 </a> 59 + {{ if not (eq .RoundNumber 0) }} 60 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2" 61 + hx-boost="true" 62 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 63 + {{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">interdiff</span> 64 + </a> 65 + <span id="interdiff-error-{{.RoundNumber}}"></span> 66 + {{ end }} 126 67 </div> 127 68 </summary> 128 - <div class="md:pl-12 flex flex-col gap-2 mt-2 relative"> 129 - {{ range .Comments }} 130 - <div id="comment-{{.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 69 + 70 + {{ if .IsFormatPatch }} 71 + {{ $patches := .AsFormatPatch }} 72 + {{ $round := .RoundNumber }} 73 + <details class="group py-2 md:ml-[3.5rem] text-gray-500 dark:text-gray-400 flex flex-col gap-2 relative text-sm"> 74 + <summary class="py-1 list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 75 + {{ $s := "s" }} 76 + {{ if eq (len $patches) 1 }} 77 + {{ $s = "" }} 78 + {{ end }} 79 + <div class="group-open:hidden flex items-center gap-2 ml-2"> 80 + {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $patches }} commit{{$s}} 81 + </div> 82 + <div class="hidden group-open:flex items-center gap-2 ml-2"> 83 + {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $patches }} commit{{$s}} 84 + </div> 85 + </summary> 86 + {{ range $patches }} 87 + <div id="commit-{{.SHA}}" class="py-1 px-2 relative w-full md:max-w-3/5 md:w-fit flex flex-col"> 88 + <div class="flex items-center gap-2"> 89 + {{ i "git-commit-horizontal" "w-4 h-4" }} 90 + <div class="text-sm text-gray-500 dark:text-gray-400"> 91 + {{ if not $.Pull.IsPatchBased }} 92 + {{ $fullRepo := $.RepoInfo.FullName }} 93 + {{ if $.Pull.IsForkBased }} 94 + {{ if $.Pull.PullSource.Repo }} 95 + {{ $fullRepo = printf "%s/%s" $owner $.Pull.PullSource.Repo.Name }} 96 + <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a> 97 + {{ else }} 98 + <span class="font-mono">{{ slice .SHA 0 8 }}</span> 99 + {{ end }} 100 + {{ end }} 101 + {{ else }} 102 + <span class="font-mono">{{ slice .SHA 0 8 }}</span> 103 + {{ end }} 104 + </div> 105 + <div class="flex items-center"> 106 + <span>{{ .Title }}</span> 107 + {{ if gt (len .Body) 0 }} 108 + <button 109 + class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 110 + hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 111 + > 112 + {{ i "ellipsis" "w-3 h-3" }} 113 + </button> 114 + {{ end }} 115 + </div> 116 + </div> 117 + {{ if gt (len .Body) 0 }} 118 + <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2"> 119 + {{ nl2br .Body }} 120 + </p> 121 + {{ end }} 122 + </div> 123 + {{ end }} 124 + </details> 125 + {{ end }} 126 + 127 + 128 + <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 129 + {{ range $cidx, $c := .Comments }} 130 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 131 + {{ if gt $cidx 0 }} 131 132 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 133 + {{ end }} 132 134 <div class="text-sm text-gray-500 dark:text-gray-400"> 133 - {{ $owner := index $.DidHandleMap .OwnerDid }} 135 + {{ $owner := index $.DidHandleMap $c.OwnerDid }} 134 136 <a href="/{{$owner}}">{{$owner}}</a> 135 137 <span class="before:content-['ยท']"></span> 136 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"><time>{{ .Created | shortTimeFmt }}</time></a> 138 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"><time>{{ $c.Created | shortTimeFmt }}</time></a> 137 139 </div> 138 140 <div class="prose dark:prose-invert"> 139 - {{ .Body | markdown }} 141 + {{ $c.Body | markdown }} 140 142 </div> 141 143 </div> 142 144 {{ end }} ··· 147 149 {{ end }} 148 150 149 151 {{ if $.LoggedInUser }} 150 - {{ template "fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck) }} 152 + {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck) }} 151 153 {{ else }} 152 154 <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white"> 153 155 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> ··· 156 158 {{ end }} 157 159 </div> 158 160 </details> 159 - <hr class="md:hidden"/> 161 + <hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/> 160 162 {{ end }} 161 163 {{ end }} 162 164 {{ end }} ··· 164 166 {{ define "mergeStatus" }} 165 167 {{ if .Pull.State.IsClosed }} 166 168 <div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 167 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 168 169 <div class="flex items-center gap-2 text-black dark:text-white"> 169 170 {{ i "ban" "w-4 h-4" }} 170 171 <span class="font-medium">closed without merging</span ··· 173 174 </div> 174 175 {{ else if .Pull.State.IsMerged }} 175 176 <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 176 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 177 177 <div class="flex items-center gap-2 text-purple-500 dark:text-purple-300"> 178 178 {{ i "git-merge" "w-4 h-4" }} 179 179 <span class="font-medium">pull request successfully merged</span ··· 182 182 </div> 183 183 {{ else if and .MergeCheck .MergeCheck.Error }} 184 184 <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 185 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 186 185 <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 187 186 {{ i "triangle-alert" "w-4 h-4" }} 188 187 <span class="font-medium">{{ .MergeCheck.Error }}</span> ··· 190 189 </div> 191 190 {{ else if and .MergeCheck .MergeCheck.IsConflicted }} 192 191 <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 193 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 194 192 <div class="flex flex-col gap-2 text-red-500 dark:text-red-300"> 195 193 <div class="flex items-center gap-2"> 196 194 {{ i "triangle-alert" "w-4 h-4" }} ··· 210 208 </div> 211 209 {{ else if .MergeCheck }} 212 210 <div class="bg-green-50 dark:bg-green-900 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 213 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 214 211 <div class="flex items-center gap-2 text-green-500 dark:text-green-300"> 215 212 {{ i "circle-check-big" "w-4 h-4" }} 216 213 <span class="font-medium">no conflicts, ready to merge</span> ··· 222 219 {{ define "resubmitStatus" }} 223 220 {{ if .ResubmitCheck.Yes }} 224 221 <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 225 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 226 222 <div class="flex items-center gap-2 text-amber-500 dark:text-amber-300"> 227 223 {{ i "triangle-alert" "w-4 h-4" }} 228 224 <span class="font-medium">this branch has been updated, consider resubmitting</span> ··· 230 226 </div> 231 227 {{ end }} 232 228 {{ end }} 229 + 230 + {{ define "commits" }} 231 + {{ end }}
+29 -6
appview/pages/templates/repo/pulls/pulls.html
··· 5 5 <p class="dark:text-white"> 6 6 filtering 7 7 <select 8 - class="border px-1 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600 dark:text-white" 8 + class="border p-1 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600 dark:text-white" 9 9 onchange="window.location.href = '/{{ .RepoInfo.FullName }}/pulls?state=' + this.value" 10 10 > 11 11 <option value="open" {{ if .FilteringBy.IsOpen }}selected{{ end }}> ··· 22 22 </p> 23 23 <a 24 24 href="/{{ .RepoInfo.FullName }}/pulls/new" 25 - class="btn text-sm flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:bg-gray-700 dark:hover:bg-gray-600" 25 + class="btn text-sm flex items-center gap-2 no-underline hover:no-underline" 26 26 > 27 - {{ i "git-pull-request" "w-4 h-4" }} 28 - <span>new pull request</span> 27 + {{ i "git-pull-request-create" "w-4 h-4" }} 28 + <span>new</span> 29 29 </a> 30 30 </div> 31 31 <div class="error" id="pulls"></div> ··· 78 78 {{ .TargetBranch }} 79 79 </span> 80 80 </span> 81 - {{ if not .IsPatch }} 81 + {{ if not .IsPatchBased }} 82 82 <span>from 83 - {{ if not .IsSameRepoBranch }} 83 + {{ if .IsForkBased }} 84 + {{ if .PullSource.Repo }} 84 85 <a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a> 86 + {{ else }} 87 + <span class="italic">[deleted fork]</span> 88 + {{ end }} 85 89 {{ end }} 86 90 87 91 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> ··· 89 93 </span> 90 94 </span> 91 95 {{ end }} 96 + <span class="before:content-['ยท']"> 97 + {{ $latestRound := .LastRoundNumber }} 98 + {{ $lastSubmission := index .Submissions $latestRound }} 99 + round 100 + <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 101 + #{{ .LastRoundNumber }} 102 + </span> 103 + {{ $commentCount := len $lastSubmission.Comments }} 104 + {{ $s := "s" }} 105 + {{ if eq $commentCount 1 }} 106 + {{ $s = "" }} 107 + {{ end }} 108 + 109 + {{ if eq $commentCount 0 }} 110 + awaiting comments 111 + {{ else }} 112 + recieved {{ len $lastSubmission.Comments}} comment{{$s}} 113 + {{ end }} 114 + </span> 92 115 </p> 93 116 </div> 94 117 {{ end }}
+4 -3
appview/pages/templates/repo/tree.html
··· 28 28 {{ $stats := .TreeStats }} 29 29 30 30 <span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span> 31 - <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 32 31 {{ if eq $stats.NumFolders 1 }} 33 - <span>{{ $stats.NumFolders }} folder</span> 34 32 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 33 + <span>{{ $stats.NumFolders }} folder</span> 35 34 {{ else if gt $stats.NumFolders 1 }} 35 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 36 36 <span>{{ $stats.NumFolders }} folders</span> 37 - <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 38 37 {{ end }} 39 38 40 39 {{ if eq $stats.NumFiles 1 }} 40 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 41 41 <span>{{ $stats.NumFiles }} file</span> 42 42 {{ else if gt $stats.NumFiles 1 }} 43 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 43 44 <span>{{ $stats.NumFiles }} files</span> 44 45 {{ end }} 45 46
+1 -1
appview/pages/templates/timeline.html
··· 23 23 </div> 24 24 <div class="italic text-lg"> 25 25 tightly-knit social coding, <a href="/login" class="underline inline-flex gap-1 items-center">join now {{ i "arrow-right" "w-4 h-4" }}</a> 26 - <p class="pt-5 px-10 text-sm text-gray-500 dark:text-gray-400">Join our IRC channel: <a href="https://web.libera.chat/#tangled"><code>#tangled</code> on Libera Chat</a>. 26 + <p class="pt-5 px-10 text-sm text-gray-500 dark:text-gray-400">Join our <a href="https://chat.tangled.sh">Discord</a>or IRC channel: <a href="https://web.libera.chat/#tangled"><code>#tangled</code> on Libera Chat</a>. 27 27 Read an introduction to Tangled <a href="https://blog.tangled.sh/intro">here</a>.</p> 28 28 </div> 29 29 </div>
+17
appview/pages/templates/user/fragments/follow.html
··· 1 + {{ define "user/fragments/follow" }} 2 + <button id="followBtn" 3 + class="btn mt-2 w-full" 4 + 5 + {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 + hx-post="/follow?subject={{.UserDid}}" 7 + {{ else }} 8 + hx-delete="/follow?subject={{.UserDid}}" 9 + {{ end }} 10 + 11 + hx-trigger="click" 12 + hx-target="#followBtn" 13 + hx-swap="outerHTML" 14 + > 15 + {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }} 16 + </button> 17 + {{ end }}
+1 -1
appview/pages/templates/user/login.html
··· 70 70 </button> 71 71 </form> 72 72 <p class="text-sm text-gray-500"> 73 - Join our IRC channel: 73 + Join our <a href="https://chat.tangled.sh">Discord</a> or IRC channel: 74 74 <a href="https://web.libera.chat/#tangled" 75 75 ><code>#tangled</code> on Libera Chat</a 76 76 >.
+2 -2
appview/pages/templates/user/profile.html
··· 179 179 180 180 181 181 {{ if gt $stats.Closed 0 }} 182 - <span class="px-2 py-1/2 text-sm rounded text-black dark:text-white bg-gray-50 dark:bg-gray-700 "> 182 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 183 183 {{$stats.Closed}} closed 184 184 </span> 185 185 {{ end }} ··· 247 247 </div> 248 248 249 249 {{ if ne .FollowStatus.String "IsSelf" }} 250 - {{ template "fragments/follow" . }} 250 + {{ template "user/fragments/follow" . }} 251 251 {{ end }} 252 252 </div> 253 253 {{ end }}
+31
appview/pagination/page.go
··· 1 + package pagination 2 + 3 + type Page struct { 4 + Offset int // where to start from 5 + Limit int // number of items in a page 6 + } 7 + 8 + func FirstPage() Page { 9 + return Page{ 10 + Offset: 0, 11 + Limit: 10, 12 + } 13 + } 14 + 15 + func (p Page) Previous() Page { 16 + if p.Offset-p.Limit < 0 { 17 + return FirstPage() 18 + } else { 19 + return Page{ 20 + Offset: p.Offset - p.Limit, 21 + Limit: p.Limit, 22 + } 23 + } 24 + } 25 + 26 + func (p Page) Next() Page { 27 + return Page{ 28 + Offset: p.Offset + p.Limit, 29 + Limit: p.Limit, 30 + } 31 + }
+451
appview/settings/settings.go
··· 1 + package settings 2 + 3 + import ( 4 + "database/sql" 5 + "errors" 6 + "fmt" 7 + "log" 8 + "net/http" 9 + "net/url" 10 + "strings" 11 + "time" 12 + 13 + "github.com/go-chi/chi/v5" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/appview" 16 + "tangled.sh/tangled.sh/core/appview/auth" 17 + "tangled.sh/tangled.sh/core/appview/db" 18 + "tangled.sh/tangled.sh/core/appview/email" 19 + "tangled.sh/tangled.sh/core/appview/middleware" 20 + "tangled.sh/tangled.sh/core/appview/pages" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + lexutil "github.com/bluesky-social/indigo/lex/util" 24 + "github.com/gliderlabs/ssh" 25 + "github.com/google/uuid" 26 + ) 27 + 28 + type Settings struct { 29 + Db *db.DB 30 + Auth *auth.Auth 31 + Pages *pages.Pages 32 + Config *appview.Config 33 + } 34 + 35 + func (s *Settings) Router() http.Handler { 36 + r := chi.NewRouter() 37 + 38 + r.Use(middleware.AuthMiddleware(s.Auth)) 39 + 40 + r.Get("/", s.settings) 41 + 42 + r.Route("/keys", func(r chi.Router) { 43 + r.Put("/", s.keys) 44 + r.Delete("/", s.keys) 45 + }) 46 + 47 + r.Route("/emails", func(r chi.Router) { 48 + r.Put("/", s.emails) 49 + r.Delete("/", s.emails) 50 + r.Get("/verify", s.emailsVerify) 51 + r.Post("/verify/resend", s.emailsVerifyResend) 52 + r.Post("/primary", s.emailsPrimary) 53 + }) 54 + 55 + return r 56 + } 57 + 58 + func (s *Settings) settings(w http.ResponseWriter, r *http.Request) { 59 + user := s.Auth.GetUser(r) 60 + pubKeys, err := db.GetPublicKeys(s.Db, user.Did) 61 + if err != nil { 62 + log.Println(err) 63 + } 64 + 65 + emails, err := db.GetAllEmails(s.Db, user.Did) 66 + if err != nil { 67 + log.Println(err) 68 + } 69 + 70 + s.Pages.Settings(w, pages.SettingsParams{ 71 + LoggedInUser: user, 72 + PubKeys: pubKeys, 73 + Emails: emails, 74 + }) 75 + } 76 + 77 + // buildVerificationEmail creates an email.Email struct for verification emails 78 + func (s *Settings) buildVerificationEmail(emailAddr, did, code string) email.Email { 79 + verifyURL := s.verifyUrl(did, emailAddr, code) 80 + 81 + return email.Email{ 82 + APIKey: s.Config.ResendApiKey, 83 + From: "noreply@notifs.tangled.sh", 84 + To: emailAddr, 85 + Subject: "Verify your Tangled email", 86 + Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 87 + ` + verifyURL, 88 + Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p> 89 + <p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`, 90 + } 91 + } 92 + 93 + // sendVerificationEmail handles the common logic for sending verification emails 94 + func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 95 + emailToSend := s.buildVerificationEmail(emailAddr, did, code) 96 + 97 + err := email.SendEmail(emailToSend) 98 + if err != nil { 99 + log.Printf("sending email: %s", err) 100 + s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 101 + return err 102 + } 103 + 104 + return nil 105 + } 106 + 107 + func (s *Settings) emails(w http.ResponseWriter, r *http.Request) { 108 + switch r.Method { 109 + case http.MethodGet: 110 + s.Pages.Notice(w, "settings-emails", "Unimplemented.") 111 + log.Println("unimplemented") 112 + return 113 + case http.MethodPut: 114 + did := s.Auth.GetDid(r) 115 + emAddr := r.FormValue("email") 116 + emAddr = strings.TrimSpace(emAddr) 117 + 118 + if !email.IsValidEmail(emAddr) { 119 + s.Pages.Notice(w, "settings-emails-error", "Invalid email address.") 120 + return 121 + } 122 + 123 + // check if email already exists in database 124 + existingEmail, err := db.GetEmail(s.Db, did, emAddr) 125 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 126 + log.Printf("checking for existing email: %s", err) 127 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 128 + return 129 + } 130 + 131 + if err == nil { 132 + if existingEmail.Verified { 133 + s.Pages.Notice(w, "settings-emails-error", "This email is already verified.") 134 + return 135 + } 136 + 137 + s.Pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.") 138 + return 139 + } 140 + 141 + code := uuid.New().String() 142 + 143 + // Begin transaction 144 + tx, err := s.Db.Begin() 145 + if err != nil { 146 + log.Printf("failed to start transaction: %s", err) 147 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 148 + return 149 + } 150 + defer tx.Rollback() 151 + 152 + if err := db.AddEmail(tx, db.Email{ 153 + Did: did, 154 + Address: emAddr, 155 + Verified: false, 156 + VerificationCode: code, 157 + }); err != nil { 158 + log.Printf("adding email: %s", err) 159 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 160 + return 161 + } 162 + 163 + if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 164 + return 165 + } 166 + 167 + // Commit transaction 168 + if err := tx.Commit(); err != nil { 169 + log.Printf("failed to commit transaction: %s", err) 170 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 171 + return 172 + } 173 + 174 + s.Pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.") 175 + return 176 + case http.MethodDelete: 177 + did := s.Auth.GetDid(r) 178 + emailAddr := r.FormValue("email") 179 + emailAddr = strings.TrimSpace(emailAddr) 180 + 181 + // Begin transaction 182 + tx, err := s.Db.Begin() 183 + if err != nil { 184 + log.Printf("failed to start transaction: %s", err) 185 + s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 186 + return 187 + } 188 + defer tx.Rollback() 189 + 190 + if err := db.DeleteEmail(tx, did, emailAddr); err != nil { 191 + log.Printf("deleting email: %s", err) 192 + s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 193 + return 194 + } 195 + 196 + // Commit transaction 197 + if err := tx.Commit(); err != nil { 198 + log.Printf("failed to commit transaction: %s", err) 199 + s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 200 + return 201 + } 202 + 203 + s.Pages.HxLocation(w, "/settings") 204 + return 205 + } 206 + } 207 + 208 + func (s *Settings) verifyUrl(did string, email string, code string) string { 209 + var appUrl string 210 + if s.Config.Dev { 211 + appUrl = "http://" + s.Config.ListenAddr 212 + } else { 213 + appUrl = "https://tangled.sh" 214 + } 215 + 216 + return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code)) 217 + } 218 + 219 + func (s *Settings) emailsVerify(w http.ResponseWriter, r *http.Request) { 220 + q := r.URL.Query() 221 + 222 + // Get the parameters directly from the query 223 + emailAddr := q.Get("email") 224 + did := q.Get("did") 225 + code := q.Get("code") 226 + 227 + valid, err := db.CheckValidVerificationCode(s.Db, did, emailAddr, code) 228 + if err != nil { 229 + log.Printf("checking email verification: %s", err) 230 + s.Pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") 231 + return 232 + } 233 + 234 + if !valid { 235 + s.Pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.") 236 + return 237 + } 238 + 239 + // Mark email as verified in the database 240 + if err := db.MarkEmailVerified(s.Db, did, emailAddr); err != nil { 241 + log.Printf("marking email as verified: %s", err) 242 + s.Pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") 243 + return 244 + } 245 + 246 + http.Redirect(w, r, "/settings", http.StatusSeeOther) 247 + } 248 + 249 + func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { 250 + if r.Method != http.MethodPost { 251 + s.Pages.Notice(w, "settings-emails-error", "Invalid request method.") 252 + return 253 + } 254 + 255 + did := s.Auth.GetDid(r) 256 + emAddr := r.FormValue("email") 257 + emAddr = strings.TrimSpace(emAddr) 258 + 259 + if !email.IsValidEmail(emAddr) { 260 + s.Pages.Notice(w, "settings-emails-error", "Invalid email address.") 261 + return 262 + } 263 + 264 + // Check if email exists and is unverified 265 + existingEmail, err := db.GetEmail(s.Db, did, emAddr) 266 + if err != nil { 267 + if errors.Is(err, sql.ErrNoRows) { 268 + s.Pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.") 269 + } else { 270 + log.Printf("checking for existing email: %s", err) 271 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 272 + } 273 + return 274 + } 275 + 276 + if existingEmail.Verified { 277 + s.Pages.Notice(w, "settings-emails-error", "This email is already verified.") 278 + return 279 + } 280 + 281 + // Check if last verification email was sent less than 10 minutes ago 282 + if existingEmail.LastSent != nil { 283 + timeSinceLastSent := time.Since(*existingEmail.LastSent) 284 + if timeSinceLastSent < 10*time.Minute { 285 + waitTime := 10*time.Minute - timeSinceLastSent 286 + s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1))) 287 + return 288 + } 289 + } 290 + 291 + // Generate new verification code 292 + code := uuid.New().String() 293 + 294 + // Begin transaction 295 + tx, err := s.Db.Begin() 296 + if err != nil { 297 + log.Printf("failed to start transaction: %s", err) 298 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 299 + return 300 + } 301 + defer tx.Rollback() 302 + 303 + // Update the verification code and last sent time 304 + if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil { 305 + log.Printf("updating email verification: %s", err) 306 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 307 + return 308 + } 309 + 310 + // Send verification email 311 + if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 312 + return 313 + } 314 + 315 + // Commit transaction 316 + if err := tx.Commit(); err != nil { 317 + log.Printf("failed to commit transaction: %s", err) 318 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 319 + return 320 + } 321 + 322 + s.Pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.") 323 + } 324 + 325 + func (s *Settings) emailsPrimary(w http.ResponseWriter, r *http.Request) { 326 + did := s.Auth.GetDid(r) 327 + emailAddr := r.FormValue("email") 328 + emailAddr = strings.TrimSpace(emailAddr) 329 + 330 + if emailAddr == "" { 331 + s.Pages.Notice(w, "settings-emails-error", "Email address cannot be empty.") 332 + return 333 + } 334 + 335 + if err := db.MakeEmailPrimary(s.Db, did, emailAddr); err != nil { 336 + log.Printf("setting primary email: %s", err) 337 + s.Pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") 338 + return 339 + } 340 + 341 + s.Pages.HxLocation(w, "/settings") 342 + } 343 + 344 + func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { 345 + switch r.Method { 346 + case http.MethodGet: 347 + s.Pages.Notice(w, "settings-keys", "Unimplemented.") 348 + log.Println("unimplemented") 349 + return 350 + case http.MethodPut: 351 + did := s.Auth.GetDid(r) 352 + key := r.FormValue("key") 353 + key = strings.TrimSpace(key) 354 + name := r.FormValue("name") 355 + client, _ := s.Auth.AuthorizedClient(r) 356 + 357 + _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) 358 + if err != nil { 359 + log.Printf("parsing public key: %s", err) 360 + s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 361 + return 362 + } 363 + 364 + rkey := appview.TID() 365 + 366 + tx, err := s.Db.Begin() 367 + if err != nil { 368 + log.Printf("failed to start tx; adding public key: %s", err) 369 + s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 370 + return 371 + } 372 + defer tx.Rollback() 373 + 374 + if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil { 375 + log.Printf("adding public key: %s", err) 376 + s.Pages.Notice(w, "settings-keys", "Failed to add public key.") 377 + return 378 + } 379 + 380 + // store in pds too 381 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 382 + Collection: tangled.PublicKeyNSID, 383 + Repo: did, 384 + Rkey: rkey, 385 + Record: &lexutil.LexiconTypeDecoder{ 386 + Val: &tangled.PublicKey{ 387 + Created: time.Now().Format(time.RFC3339), 388 + Key: key, 389 + Name: name, 390 + }}, 391 + }) 392 + // invalid record 393 + if err != nil { 394 + log.Printf("failed to create record: %s", err) 395 + s.Pages.Notice(w, "settings-keys", "Failed to create record.") 396 + return 397 + } 398 + 399 + log.Println("created atproto record: ", resp.Uri) 400 + 401 + err = tx.Commit() 402 + if err != nil { 403 + log.Printf("failed to commit tx; adding public key: %s", err) 404 + s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 405 + return 406 + } 407 + 408 + s.Pages.HxLocation(w, "/settings") 409 + return 410 + 411 + case http.MethodDelete: 412 + did := s.Auth.GetDid(r) 413 + q := r.URL.Query() 414 + 415 + name := q.Get("name") 416 + rkey := q.Get("rkey") 417 + key := q.Get("key") 418 + 419 + log.Println(name) 420 + log.Println(rkey) 421 + log.Println(key) 422 + 423 + client, _ := s.Auth.AuthorizedClient(r) 424 + 425 + if err := db.RemovePublicKey(s.Db, did, name, key); err != nil { 426 + log.Printf("removing public key: %s", err) 427 + s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 428 + return 429 + } 430 + 431 + if rkey != "" { 432 + // remove from pds too 433 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 434 + Collection: tangled.PublicKeyNSID, 435 + Repo: did, 436 + Rkey: rkey, 437 + }) 438 + 439 + // invalid record 440 + if err != nil { 441 + log.Printf("failed to delete record from PDS: %s", err) 442 + s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.") 443 + return 444 + } 445 + } 446 + log.Println("deleted successfully") 447 + 448 + s.Pages.HxLocation(w, "/settings") 449 + return 450 + } 451 + }
+2 -1
appview/state/follow.go
··· 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 10 tangled "tangled.sh/tangled.sh/core/api/tangled" 11 + "tangled.sh/tangled.sh/core/appview" 11 12 "tangled.sh/tangled.sh/core/appview/db" 12 13 "tangled.sh/tangled.sh/core/appview/pages" 13 14 ) ··· 36 37 switch r.Method { 37 38 case http.MethodPost: 38 39 createdAt := time.Now().Format(time.RFC3339) 39 - rkey := s.TID() 40 + rkey := appview.TID() 40 41 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 41 42 Collection: tangled.GraphFollowNSID, 42 43 Repo: currentUser.Did,
+16 -93
appview/state/middleware.go
··· 8 8 "strings" 9 9 "time" 10 10 11 - comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "slices" 12 + 12 13 "github.com/bluesky-social/indigo/atproto/identity" 13 - "github.com/bluesky-social/indigo/xrpc" 14 14 "github.com/go-chi/chi/v5" 15 - "tangled.sh/tangled.sh/core/appview" 16 - "tangled.sh/tangled.sh/core/appview/auth" 17 15 "tangled.sh/tangled.sh/core/appview/db" 16 + "tangled.sh/tangled.sh/core/appview/middleware" 18 17 ) 19 18 20 - type Middleware func(http.Handler) http.Handler 21 - 22 - func AuthMiddleware(s *State) Middleware { 23 - return func(next http.Handler) http.Handler { 24 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 - redirectFunc := func(w http.ResponseWriter, r *http.Request) { 26 - http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 27 - } 28 - if r.Header.Get("HX-Request") == "true" { 29 - redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 30 - w.Header().Set("HX-Redirect", "/login") 31 - w.WriteHeader(http.StatusOK) 32 - } 33 - } 34 - 35 - session, err := s.auth.GetSession(r) 36 - if session.IsNew || err != nil { 37 - log.Printf("not logged in, redirecting") 38 - redirectFunc(w, r) 39 - return 40 - } 41 - 42 - authorized, ok := session.Values[appview.SessionAuthenticated].(bool) 43 - if !ok || !authorized { 44 - log.Printf("not logged in, redirecting") 45 - redirectFunc(w, r) 46 - return 47 - } 48 - 49 - // refresh if nearing expiry 50 - // TODO: dedup with /login 51 - expiryStr := session.Values[appview.SessionExpiry].(string) 52 - expiry, err := time.Parse(time.RFC3339, expiryStr) 53 - if err != nil { 54 - log.Println("invalid expiry time", err) 55 - redirectFunc(w, r) 56 - return 57 - } 58 - pdsUrl, ok1 := session.Values[appview.SessionPds].(string) 59 - did, ok2 := session.Values[appview.SessionDid].(string) 60 - refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string) 61 - 62 - if !ok1 || !ok2 || !ok3 { 63 - log.Println("invalid expiry time", err) 64 - redirectFunc(w, r) 65 - return 66 - } 67 - 68 - if time.Now().After(expiry) { 69 - log.Println("token expired, refreshing ...") 70 - 71 - client := xrpc.Client{ 72 - Host: pdsUrl, 73 - Auth: &xrpc.AuthInfo{ 74 - Did: did, 75 - AccessJwt: refreshJwt, 76 - RefreshJwt: refreshJwt, 77 - }, 78 - } 79 - atSession, err := comatproto.ServerRefreshSession(r.Context(), &client) 80 - if err != nil { 81 - log.Println("failed to refresh session", err) 82 - redirectFunc(w, r) 83 - return 84 - } 85 - 86 - sessionish := auth.RefreshSessionWrapper{atSession} 87 - 88 - err = s.auth.StoreSession(r, w, &sessionish, pdsUrl) 89 - if err != nil { 90 - log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err) 91 - return 92 - } 93 - 94 - log.Println("successfully refreshed token") 95 - } 96 - 97 - next.ServeHTTP(w, r) 98 - }) 99 - } 100 - } 101 - 102 - func knotRoleMiddleware(s *State, group string) Middleware { 19 + func knotRoleMiddleware(s *State, group string) middleware.Middleware { 103 20 return func(next http.Handler) http.Handler { 104 21 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 105 22 // requires auth also ··· 129 46 } 130 47 } 131 48 132 - func KnotOwner(s *State) Middleware { 49 + func KnotOwner(s *State) middleware.Middleware { 133 50 return knotRoleMiddleware(s, "server:owner") 134 51 } 135 52 136 - func RepoPermissionMiddleware(s *State, requiredPerm string) Middleware { 53 + func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware { 137 54 return func(next http.Handler) http.Handler { 138 55 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 139 56 // requires auth also ··· 150 67 return 151 68 } 152 69 153 - ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.OwnerSlashRepo(), requiredPerm) 70 + ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 154 71 if err != nil || !ok { 155 72 // we need a logged in user 156 73 log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo()) ··· 173 90 }) 174 91 } 175 92 176 - func ResolveIdent(s *State) Middleware { 93 + func ResolveIdent(s *State) middleware.Middleware { 94 + excluded := []string{"favicon.ico"} 95 + 177 96 return func(next http.Handler) http.Handler { 178 97 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 179 98 didOrHandle := chi.URLParam(req, "user") 99 + if slices.Contains(excluded, didOrHandle) { 100 + next.ServeHTTP(w, req) 101 + return 102 + } 180 103 181 104 id, err := s.resolver.ResolveIdent(req.Context(), didOrHandle) 182 105 if err != nil { ··· 193 116 } 194 117 } 195 118 196 - func ResolveRepo(s *State) Middleware { 119 + func ResolveRepo(s *State) middleware.Middleware { 197 120 return func(next http.Handler) http.Handler { 198 121 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 199 122 repoName := chi.URLParam(req, "repo") ··· 222 145 } 223 146 224 147 // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 225 - func ResolvePull(s *State) Middleware { 148 + func ResolvePull(s *State) middleware.Middleware { 226 149 return func(next http.Handler) http.Handler { 227 150 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 228 151 f, err := fullyResolvedRepo(r)
+4 -4
appview/state/profile.go
··· 5 5 "log" 6 6 "net/http" 7 7 8 + "github.com/bluesky-social/indigo/atproto/identity" 8 9 "github.com/go-chi/chi/v5" 9 10 "tangled.sh/tangled.sh/core/appview/db" 10 11 "tangled.sh/tangled.sh/core/appview/pages" ··· 17 18 return 18 19 } 19 20 20 - ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle) 21 - if err != nil { 22 - log.Printf("resolving identity: %s", err) 23 - w.WriteHeader(http.StatusNotFound) 21 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 22 + if !ok { 23 + s.pages.Error404(w) 24 24 return 25 25 } 26 26
+521 -265
appview/state/pull.go
··· 10 10 "net/http" 11 11 "net/url" 12 12 "strconv" 13 - "strings" 14 13 "time" 15 14 16 - "github.com/go-chi/chi/v5" 17 15 "tangled.sh/tangled.sh/core/api/tangled" 16 + "tangled.sh/tangled.sh/core/appview" 18 17 "tangled.sh/tangled.sh/core/appview/auth" 19 18 "tangled.sh/tangled.sh/core/appview/db" 20 19 "tangled.sh/tangled.sh/core/appview/pages" 20 + "tangled.sh/tangled.sh/core/patchutil" 21 21 "tangled.sh/tangled.sh/core/types" 22 22 23 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 25 lexutil "github.com/bluesky-social/indigo/lex/util" 26 + "github.com/go-chi/chi/v5" 26 27 ) 27 28 28 29 // htmx fragment ··· 120 121 resubmitResult = s.resubmitCheck(f, pull) 121 122 } 122 123 123 - var pullSourceRepo *db.Repo 124 - if pull.PullSource != nil { 125 - if pull.PullSource.RepoAt != nil { 126 - pullSourceRepo, err = db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 127 - if err != nil { 128 - log.Printf("failed to get repo by at uri: %v", err) 129 - return 130 - } 131 - } 132 - } 133 - 134 124 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 135 - LoggedInUser: user, 136 - RepoInfo: f.RepoInfo(s, user), 137 - DidHandleMap: didHandleMap, 138 - Pull: pull, 139 - PullSourceRepo: pullSourceRepo, 140 - MergeCheck: mergeCheckResponse, 141 - ResubmitCheck: resubmitResult, 125 + LoggedInUser: user, 126 + RepoInfo: f.RepoInfo(s, user), 127 + DidHandleMap: didHandleMap, 128 + Pull: pull, 129 + MergeCheck: mergeCheckResponse, 130 + ResubmitCheck: resubmitResult, 142 131 }) 143 132 } 144 133 ··· 254 243 255 244 latestSubmission := pull.Submissions[pull.LastRoundNumber()] 256 245 if latestSubmission.SourceRev != result.Branch.Hash { 246 + fmt.Println(latestSubmission.SourceRev, result.Branch.Hash) 257 247 return pages.ShouldResubmit 258 248 } 259 249 ··· 294 284 } 295 285 } 296 286 287 + diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch) 288 + 297 289 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 298 290 LoggedInUser: user, 299 291 DidHandleMap: didHandleMap, ··· 301 293 Pull: pull, 302 294 Round: roundIdInt, 303 295 Submission: pull.Submissions[roundIdInt], 304 - Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch), 296 + Diff: &diff, 305 297 }) 306 298 307 299 } 308 300 301 + func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 302 + user := s.auth.GetUser(r) 303 + 304 + f, err := fullyResolvedRepo(r) 305 + if err != nil { 306 + log.Println("failed to get repo and knot", err) 307 + return 308 + } 309 + 310 + pull, ok := r.Context().Value("pull").(*db.Pull) 311 + if !ok { 312 + log.Println("failed to get pull") 313 + s.pages.Notice(w, "pull-error", "Failed to get pull.") 314 + return 315 + } 316 + 317 + roundId := chi.URLParam(r, "round") 318 + roundIdInt, err := strconv.Atoi(roundId) 319 + if err != nil || roundIdInt >= len(pull.Submissions) { 320 + http.Error(w, "bad round id", http.StatusBadRequest) 321 + log.Println("failed to parse round id", err) 322 + return 323 + } 324 + 325 + if roundIdInt == 0 { 326 + http.Error(w, "bad round id", http.StatusBadRequest) 327 + log.Println("cannot interdiff initial submission") 328 + return 329 + } 330 + 331 + identsToResolve := []string{pull.OwnerDid} 332 + resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 333 + didHandleMap := make(map[string]string) 334 + for _, identity := range resolvedIds { 335 + if !identity.Handle.IsInvalidHandle() { 336 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 337 + } else { 338 + didHandleMap[identity.DID.String()] = identity.DID.String() 339 + } 340 + } 341 + 342 + currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch) 343 + if err != nil { 344 + log.Println("failed to interdiff; current patch malformed") 345 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 346 + return 347 + } 348 + 349 + previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch) 350 + if err != nil { 351 + log.Println("failed to interdiff; previous patch malformed") 352 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 353 + return 354 + } 355 + 356 + interdiff := patchutil.Interdiff(previousPatch, currentPatch) 357 + 358 + s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 359 + LoggedInUser: s.auth.GetUser(r), 360 + RepoInfo: f.RepoInfo(s, user), 361 + Pull: pull, 362 + Round: roundIdInt, 363 + DidHandleMap: didHandleMap, 364 + Interdiff: interdiff, 365 + }) 366 + return 367 + } 368 + 309 369 func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 310 370 pull, ok := r.Context().Value("pull").(*db.Pull) 311 371 if !ok { ··· 369 429 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 370 430 if err != nil { 371 431 log.Printf("failed to get repo by at uri: %v", err) 372 - return 432 + continue 433 + } else { 434 + p.PullSource.Repo = pullSourceRepo 373 435 } 374 436 } 375 - p.PullSource.Repo = pullSourceRepo 376 437 } 377 438 } 378 439 ··· 463 524 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 464 525 Collection: tangled.RepoPullCommentNSID, 465 526 Repo: user.Did, 466 - Rkey: s.TID(), 527 + Rkey: appview.TID(), 467 528 Record: &lexutil.LexiconTypeDecoder{ 468 529 Val: &tangled.RepoPullComment{ 469 530 Repo: &atUri, ··· 556 617 sourceBranch := r.FormValue("sourceBranch") 557 618 patch := r.FormValue("patch") 558 619 559 - // Validate required fields for all PR types 560 - if title == "" || body == "" || targetBranch == "" { 561 - s.pages.Notice(w, "pull", "Title, body and target branch are required.") 562 - return 563 - } 564 - 565 - us, err := NewUnsignedClient(f.Knot, s.config.Dev) 566 - if err != nil { 567 - log.Println("failed to create unsigned client to %s: %v", f.Knot, err) 568 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 569 - return 570 - } 571 - 572 - caps, err := us.Capabilities() 573 - if err != nil { 574 - log.Println("error fetching knot caps", f.Knot, err) 575 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 620 + if targetBranch == "" { 621 + s.pages.Notice(w, "pull", "Target branch is required.") 576 622 return 577 623 } 578 624 ··· 581 627 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 582 628 isForkBased := fromFork != "" && sourceBranch != "" 583 629 isPatchBased := patch != "" && !isBranchBased && !isForkBased 630 + 631 + if isPatchBased && !patchutil.IsFormatPatch(patch) { 632 + if title == "" { 633 + s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 634 + return 635 + } 636 + } 584 637 585 638 // Validate we have at least one valid PR creation method 586 639 if !isBranchBased && !isPatchBased && !isForkBased { ··· 594 647 return 595 648 } 596 649 650 + us, err := NewUnsignedClient(f.Knot, s.config.Dev) 651 + if err != nil { 652 + log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 653 + s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 654 + return 655 + } 656 + 657 + caps, err := us.Capabilities() 658 + if err != nil { 659 + log.Println("error fetching knot caps", f.Knot, err) 660 + s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 661 + return 662 + } 663 + 664 + if !caps.PullRequests.FormatPatch { 665 + s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 666 + return 667 + } 668 + 597 669 // Handle the PR creation based on the type 598 670 if isBranchBased { 599 671 if !caps.PullRequests.BranchSubmissions { ··· 634 706 return 635 707 } 636 708 637 - resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 638 - switch resp.StatusCode { 639 - case 404: 640 - case 400: 641 - s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 642 - return 643 - } 644 - 645 - respBody, err := io.ReadAll(resp.Body) 709 + comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 646 710 if err != nil { 647 - log.Println("failed to compare across branches") 648 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 711 + log.Println("failed to compare", err) 712 + s.pages.Notice(w, "pull", err.Error()) 649 713 return 650 714 } 651 - defer resp.Body.Close() 652 715 653 - var diffTreeResponse types.RepoDiffTreeResponse 654 - err = json.Unmarshal(respBody, &diffTreeResponse) 655 - if err != nil { 656 - log.Println("failed to unmarshal diff tree response", err) 657 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 658 - return 659 - } 716 + sourceRev := comparison.Rev2 717 + patch := comparison.Patch 660 718 661 - sourceRev := diffTreeResponse.DiffTree.Rev2 662 - patch := diffTreeResponse.DiffTree.Patch 663 - 664 - if !isPatchValid(patch) { 719 + if !patchutil.IsPatchValid(patch) { 665 720 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 666 721 return 667 722 } ··· 670 725 } 671 726 672 727 func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) { 673 - if !isPatchValid(patch) { 728 + if !patchutil.IsPatchValid(patch) { 674 729 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 675 730 return 676 731 } ··· 730 785 // hiddenRef: hidden/feature-1/main (on repo-fork) 731 786 // targetBranch: main (on repo-1) 732 787 // sourceBranch: feature-1 (on repo-fork) 733 - diffResp, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 788 + comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 734 789 if err != nil { 735 790 log.Println("failed to compare across branches", err) 736 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 791 + s.pages.Notice(w, "pull", err.Error()) 737 792 return 738 793 } 739 794 740 - respBody, err := io.ReadAll(diffResp.Body) 741 - if err != nil { 742 - log.Println("failed to read response body", err) 743 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 744 - return 745 - } 795 + sourceRev := comparison.Rev2 796 + patch := comparison.Patch 746 797 747 - defer resp.Body.Close() 748 - 749 - var diffTreeResponse types.RepoDiffTreeResponse 750 - err = json.Unmarshal(respBody, &diffTreeResponse) 751 - if err != nil { 752 - log.Println("failed to unmarshal diff tree response", err) 753 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 754 - return 755 - } 756 - 757 - sourceRev := diffTreeResponse.DiffTree.Rev2 758 - patch := diffTreeResponse.DiffTree.Patch 759 - 760 - if !isPatchValid(patch) { 798 + if !patchutil.IsPatchValid(patch) { 761 799 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 762 800 return 763 801 } ··· 775 813 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}) 776 814 } 777 815 778 - func (s *State) createPullRequest(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch, sourceRev string, pullSource *db.PullSource, recordPullSource *tangled.RepoPull_Source) { 816 + func (s *State) createPullRequest( 817 + w http.ResponseWriter, 818 + r *http.Request, 819 + f *FullyResolvedRepo, 820 + user *auth.User, 821 + title, body, targetBranch string, 822 + patch string, 823 + sourceRev string, 824 + pullSource *db.PullSource, 825 + recordPullSource *tangled.RepoPull_Source, 826 + ) { 779 827 tx, err := s.db.BeginTx(r.Context(), nil) 780 828 if err != nil { 781 829 log.Println("failed to start tx") ··· 784 832 } 785 833 defer tx.Rollback() 786 834 787 - rkey := s.TID() 835 + // We've already checked earlier if it's diff-based and title is empty, 836 + // so if it's still empty now, it's intentionally skipped owing to format-patch. 837 + if title == "" { 838 + formatPatches, err := patchutil.ExtractPatches(patch) 839 + if err != nil { 840 + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 841 + return 842 + } 843 + if len(formatPatches) == 0 { 844 + s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 845 + return 846 + } 847 + 848 + title = formatPatches[0].Title 849 + body = formatPatches[0].Body 850 + } 851 + 852 + rkey := appview.TID() 788 853 initialSubmission := db.PullSubmission{ 789 854 Patch: patch, 790 855 SourceRev: sourceRev, ··· 838 903 } 839 904 840 905 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 906 + } 907 + 908 + func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) { 909 + _, err := fullyResolvedRepo(r) 910 + if err != nil { 911 + log.Println("failed to get repo and knot", err) 912 + return 913 + } 914 + 915 + patch := r.FormValue("patch") 916 + if patch == "" { 917 + s.pages.Notice(w, "patch-error", "Patch is required.") 918 + return 919 + } 920 + 921 + if patch == "" || !patchutil.IsPatchValid(patch) { 922 + s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 923 + return 924 + } 925 + 926 + if patchutil.IsFormatPatch(patch) { 927 + s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.") 928 + } else { 929 + s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") 930 + } 841 931 } 842 932 843 933 func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { ··· 1015 1105 }) 1016 1106 return 1017 1107 case http.MethodPost: 1018 - patch := r.FormValue("patch") 1019 - var sourceRev string 1020 - var recordPullSource *tangled.RepoPull_Source 1021 - 1022 - var ownerDid, repoName, knotName string 1023 - var isSameRepo bool = pull.IsSameRepoBranch() 1024 - sourceBranch := pull.PullSource.Branch 1025 - targetBranch := pull.TargetBranch 1026 - recordPullSource = &tangled.RepoPull_Source{ 1027 - Branch: sourceBranch, 1108 + if pull.IsPatchBased() { 1109 + s.resubmitPatch(w, r) 1110 + return 1111 + } else if pull.IsBranchBased() { 1112 + s.resubmitBranch(w, r) 1113 + return 1114 + } else if pull.IsForkBased() { 1115 + s.resubmitFork(w, r) 1116 + return 1028 1117 } 1118 + } 1119 + } 1029 1120 1030 - isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 1031 - if isSameRepo && isPushAllowed { 1032 - ownerDid = f.OwnerDid() 1033 - repoName = f.RepoName 1034 - knotName = f.Knot 1035 - } else if !isSameRepo { 1036 - sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1037 - if err != nil { 1038 - log.Println("failed to get source repo", err) 1039 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1040 - return 1041 - } 1042 - ownerDid = sourceRepo.Did 1043 - repoName = sourceRepo.Name 1044 - knotName = sourceRepo.Knot 1045 - } 1121 + func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1122 + user := s.auth.GetUser(r) 1046 1123 1047 - if sourceBranch != "" && knotName != "" { 1048 - // extract patch by performing compare 1049 - ksClient, err := NewUnsignedClient(knotName, s.config.Dev) 1050 - if err != nil { 1051 - log.Printf("failed to create client for %s: %s", knotName, err) 1052 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1053 - return 1054 - } 1124 + pull, ok := r.Context().Value("pull").(*db.Pull) 1125 + if !ok { 1126 + log.Println("failed to get pull") 1127 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1128 + return 1129 + } 1055 1130 1056 - if !isSameRepo { 1057 - secret, err := db.GetRegistrationKey(s.db, knotName) 1058 - if err != nil { 1059 - log.Printf("failed to get registration key for %s: %s", knotName, err) 1060 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1061 - return 1062 - } 1063 - // update the hidden tracking branch to latest 1064 - signedClient, err := NewSignedClient(knotName, secret, s.config.Dev) 1065 - if err != nil { 1066 - log.Printf("failed to create signed client for %s: %s", knotName, err) 1067 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1068 - return 1069 - } 1070 - resp, err := signedClient.NewHiddenRef(ownerDid, repoName, sourceBranch, targetBranch) 1071 - if err != nil || resp.StatusCode != http.StatusNoContent { 1072 - log.Printf("failed to update tracking branch: %s", err) 1073 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1074 - return 1075 - } 1076 - } 1131 + f, err := fullyResolvedRepo(r) 1132 + if err != nil { 1133 + log.Println("failed to get repo and knot", err) 1134 + return 1135 + } 1077 1136 1078 - var compareResp *http.Response 1079 - if !isSameRepo { 1080 - hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)) 1081 - compareResp, err = ksClient.Compare(ownerDid, repoName, hiddenRef, sourceBranch) 1082 - } else { 1083 - compareResp, err = ksClient.Compare(ownerDid, repoName, targetBranch, sourceBranch) 1084 - } 1085 - if err != nil { 1086 - log.Printf("failed to compare branches: %s", err) 1087 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1088 - return 1089 - } 1090 - defer compareResp.Body.Close() 1137 + if user.Did != pull.OwnerDid { 1138 + log.Println("unauthorized user") 1139 + w.WriteHeader(http.StatusUnauthorized) 1140 + return 1141 + } 1091 1142 1092 - switch compareResp.StatusCode { 1093 - case 404: 1094 - case 400: 1095 - s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 1096 - return 1097 - } 1143 + patch := r.FormValue("patch") 1098 1144 1099 - respBody, err := io.ReadAll(compareResp.Body) 1100 - if err != nil { 1101 - log.Println("failed to compare across branches") 1102 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1103 - return 1104 - } 1105 - defer compareResp.Body.Close() 1145 + if err = validateResubmittedPatch(pull, patch); err != nil { 1146 + s.pages.Notice(w, "resubmit-error", err.Error()) 1147 + return 1148 + } 1106 1149 1107 - var diffTreeResponse types.RepoDiffTreeResponse 1108 - err = json.Unmarshal(respBody, &diffTreeResponse) 1109 - if err != nil { 1110 - log.Println("failed to unmarshal diff tree response", err) 1111 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1112 - return 1113 - } 1150 + tx, err := s.db.BeginTx(r.Context(), nil) 1151 + if err != nil { 1152 + log.Println("failed to start tx") 1153 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1154 + return 1155 + } 1156 + defer tx.Rollback() 1157 + 1158 + err = db.ResubmitPull(tx, pull, patch, "") 1159 + if err != nil { 1160 + log.Println("failed to resubmit pull request", err) 1161 + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.") 1162 + return 1163 + } 1164 + client, _ := s.auth.AuthorizedClient(r) 1165 + 1166 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1167 + if err != nil { 1168 + // failed to get record 1169 + s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1170 + return 1171 + } 1172 + 1173 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1174 + Collection: tangled.RepoPullNSID, 1175 + Repo: user.Did, 1176 + Rkey: pull.Rkey, 1177 + SwapRecord: ex.Cid, 1178 + Record: &lexutil.LexiconTypeDecoder{ 1179 + Val: &tangled.RepoPull{ 1180 + Title: pull.Title, 1181 + PullId: int64(pull.PullId), 1182 + TargetRepo: string(f.RepoAt), 1183 + TargetBranch: pull.TargetBranch, 1184 + Patch: patch, // new patch 1185 + }, 1186 + }, 1187 + }) 1188 + if err != nil { 1189 + log.Println("failed to update record", err) 1190 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1191 + return 1192 + } 1193 + 1194 + if err = tx.Commit(); err != nil { 1195 + log.Println("failed to commit transaction", err) 1196 + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1197 + return 1198 + } 1199 + 1200 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1201 + return 1202 + } 1114 1203 1115 - sourceRev = diffTreeResponse.DiffTree.Rev2 1116 - patch = diffTreeResponse.DiffTree.Patch 1117 - } 1204 + func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1205 + user := s.auth.GetUser(r) 1206 + 1207 + pull, ok := r.Context().Value("pull").(*db.Pull) 1208 + if !ok { 1209 + log.Println("failed to get pull") 1210 + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1211 + return 1212 + } 1213 + 1214 + f, err := fullyResolvedRepo(r) 1215 + if err != nil { 1216 + log.Println("failed to get repo and knot", err) 1217 + return 1218 + } 1219 + 1220 + if user.Did != pull.OwnerDid { 1221 + log.Println("unauthorized user") 1222 + w.WriteHeader(http.StatusUnauthorized) 1223 + return 1224 + } 1225 + 1226 + if !f.RepoInfo(s, user).Roles.IsPushAllowed() { 1227 + log.Println("unauthorized user") 1228 + w.WriteHeader(http.StatusUnauthorized) 1229 + return 1230 + } 1118 1231 1119 - if patch == "" { 1120 - s.pages.Notice(w, "resubmit-error", "Patch is empty.") 1121 - return 1122 - } 1232 + ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 1233 + if err != nil { 1234 + log.Printf("failed to create client for %s: %s", f.Knot, err) 1235 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1236 + return 1237 + } 1123 1238 1124 - if patch == pull.LatestPatch() { 1125 - s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1126 - return 1127 - } 1239 + comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1240 + if err != nil { 1241 + log.Printf("compare request failed: %s", err) 1242 + s.pages.Notice(w, "resubmit-error", err.Error()) 1243 + return 1244 + } 1245 + 1246 + sourceRev := comparison.Rev2 1247 + patch := comparison.Patch 1128 1248 1129 - if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1130 - s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1131 - return 1132 - } 1249 + if err = validateResubmittedPatch(pull, patch); err != nil { 1250 + s.pages.Notice(w, "resubmit-error", err.Error()) 1251 + return 1252 + } 1133 1253 1134 - if !isPatchValid(patch) { 1135 - s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.") 1136 - return 1137 - } 1254 + if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1255 + s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1256 + return 1257 + } 1138 1258 1139 - tx, err := s.db.BeginTx(r.Context(), nil) 1140 - if err != nil { 1141 - log.Println("failed to start tx") 1142 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1143 - return 1144 - } 1145 - defer tx.Rollback() 1259 + tx, err := s.db.BeginTx(r.Context(), nil) 1260 + if err != nil { 1261 + log.Println("failed to start tx") 1262 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1263 + return 1264 + } 1265 + defer tx.Rollback() 1146 1266 1147 - err = db.ResubmitPull(tx, pull, patch, sourceRev) 1148 - if err != nil { 1149 - log.Println("failed to create pull request", err) 1150 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1151 - return 1152 - } 1153 - client, _ := s.auth.AuthorizedClient(r) 1267 + err = db.ResubmitPull(tx, pull, patch, sourceRev) 1268 + if err != nil { 1269 + log.Println("failed to create pull request", err) 1270 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1271 + return 1272 + } 1273 + client, _ := s.auth.AuthorizedClient(r) 1154 1274 1155 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1156 - if err != nil { 1157 - // failed to get record 1158 - s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1159 - return 1160 - } 1275 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1276 + if err != nil { 1277 + // failed to get record 1278 + s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1279 + return 1280 + } 1161 1281 1162 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1163 - Collection: tangled.RepoPullNSID, 1164 - Repo: user.Did, 1165 - Rkey: pull.Rkey, 1166 - SwapRecord: ex.Cid, 1167 - Record: &lexutil.LexiconTypeDecoder{ 1168 - Val: &tangled.RepoPull{ 1169 - Title: pull.Title, 1170 - PullId: int64(pull.PullId), 1171 - TargetRepo: string(f.RepoAt), 1172 - TargetBranch: pull.TargetBranch, 1173 - Patch: patch, // new patch 1174 - Source: recordPullSource, 1175 - }, 1282 + recordPullSource := &tangled.RepoPull_Source{ 1283 + Branch: pull.PullSource.Branch, 1284 + } 1285 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1286 + Collection: tangled.RepoPullNSID, 1287 + Repo: user.Did, 1288 + Rkey: pull.Rkey, 1289 + SwapRecord: ex.Cid, 1290 + Record: &lexutil.LexiconTypeDecoder{ 1291 + Val: &tangled.RepoPull{ 1292 + Title: pull.Title, 1293 + PullId: int64(pull.PullId), 1294 + TargetRepo: string(f.RepoAt), 1295 + TargetBranch: pull.TargetBranch, 1296 + Patch: patch, // new patch 1297 + Source: recordPullSource, 1176 1298 }, 1177 - }) 1178 - if err != nil { 1179 - log.Println("failed to update record", err) 1180 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1181 - return 1182 - } 1299 + }, 1300 + }) 1301 + if err != nil { 1302 + log.Println("failed to update record", err) 1303 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1304 + return 1305 + } 1306 + 1307 + if err = tx.Commit(); err != nil { 1308 + log.Println("failed to commit transaction", err) 1309 + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1310 + return 1311 + } 1312 + 1313 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1314 + return 1315 + } 1316 + 1317 + func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { 1318 + user := s.auth.GetUser(r) 1319 + 1320 + pull, ok := r.Context().Value("pull").(*db.Pull) 1321 + if !ok { 1322 + log.Println("failed to get pull") 1323 + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1324 + return 1325 + } 1326 + 1327 + f, err := fullyResolvedRepo(r) 1328 + if err != nil { 1329 + log.Println("failed to get repo and knot", err) 1330 + return 1331 + } 1332 + 1333 + if user.Did != pull.OwnerDid { 1334 + log.Println("unauthorized user") 1335 + w.WriteHeader(http.StatusUnauthorized) 1336 + return 1337 + } 1338 + 1339 + forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1340 + if err != nil { 1341 + log.Println("failed to get source repo", err) 1342 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1343 + return 1344 + } 1345 + 1346 + // extract patch by performing compare 1347 + ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev) 1348 + if err != nil { 1349 + log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) 1350 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1351 + return 1352 + } 1353 + 1354 + secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1355 + if err != nil { 1356 + log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1357 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1358 + return 1359 + } 1360 + 1361 + // update the hidden tracking branch to latest 1362 + signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev) 1363 + if err != nil { 1364 + log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1365 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1366 + return 1367 + } 1368 + 1369 + resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1370 + if err != nil || resp.StatusCode != http.StatusNoContent { 1371 + log.Printf("failed to update tracking branch: %s", err) 1372 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1373 + return 1374 + } 1375 + 1376 + hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)) 1377 + comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) 1378 + if err != nil { 1379 + log.Printf("failed to compare branches: %s", err) 1380 + s.pages.Notice(w, "resubmit-error", err.Error()) 1381 + return 1382 + } 1383 + 1384 + sourceRev := comparison.Rev2 1385 + patch := comparison.Patch 1386 + 1387 + if err = validateResubmittedPatch(pull, patch); err != nil { 1388 + s.pages.Notice(w, "resubmit-error", err.Error()) 1389 + return 1390 + } 1391 + 1392 + if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1393 + s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1394 + return 1395 + } 1183 1396 1184 - if err = tx.Commit(); err != nil { 1185 - log.Println("failed to commit transaction", err) 1186 - s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1187 - return 1188 - } 1397 + tx, err := s.db.BeginTx(r.Context(), nil) 1398 + if err != nil { 1399 + log.Println("failed to start tx") 1400 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1401 + return 1402 + } 1403 + defer tx.Rollback() 1404 + 1405 + err = db.ResubmitPull(tx, pull, patch, sourceRev) 1406 + if err != nil { 1407 + log.Println("failed to create pull request", err) 1408 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1409 + return 1410 + } 1411 + client, _ := s.auth.AuthorizedClient(r) 1189 1412 1190 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1413 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1414 + if err != nil { 1415 + // failed to get record 1416 + s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1191 1417 return 1192 1418 } 1419 + 1420 + repoAt := pull.PullSource.RepoAt.String() 1421 + recordPullSource := &tangled.RepoPull_Source{ 1422 + Branch: pull.PullSource.Branch, 1423 + Repo: &repoAt, 1424 + } 1425 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1426 + Collection: tangled.RepoPullNSID, 1427 + Repo: user.Did, 1428 + Rkey: pull.Rkey, 1429 + SwapRecord: ex.Cid, 1430 + Record: &lexutil.LexiconTypeDecoder{ 1431 + Val: &tangled.RepoPull{ 1432 + Title: pull.Title, 1433 + PullId: int64(pull.PullId), 1434 + TargetRepo: string(f.RepoAt), 1435 + TargetBranch: pull.TargetBranch, 1436 + Patch: patch, // new patch 1437 + Source: recordPullSource, 1438 + }, 1439 + }, 1440 + }) 1441 + if err != nil { 1442 + log.Println("failed to update record", err) 1443 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1444 + return 1445 + } 1446 + 1447 + if err = tx.Commit(); err != nil { 1448 + log.Println("failed to commit transaction", err) 1449 + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1450 + return 1451 + } 1452 + 1453 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1454 + return 1455 + } 1456 + 1457 + // validate a resubmission against a pull request 1458 + func validateResubmittedPatch(pull *db.Pull, patch string) error { 1459 + if patch == "" { 1460 + return fmt.Errorf("Patch is empty.") 1461 + } 1462 + 1463 + if patch == pull.LatestPatch() { 1464 + return fmt.Errorf("Patch is identical to previous submission.") 1465 + } 1466 + 1467 + if !patchutil.IsPatchValid(patch) { 1468 + return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1469 + } 1470 + 1471 + return nil 1193 1472 } 1194 1473 1195 1474 func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { ··· 1363 1642 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1364 1643 return 1365 1644 } 1366 - 1367 - // Very basic validation to check if it looks like a diff/patch 1368 - // A valid patch usually starts with diff or --- lines 1369 - func isPatchValid(patch string) bool { 1370 - // Basic validation to check if it looks like a diff/patch 1371 - // A valid patch usually starts with diff or --- lines 1372 - if len(patch) == 0 { 1373 - return false 1374 - } 1375 - 1376 - lines := strings.Split(patch, "\n") 1377 - if len(lines) < 2 { 1378 - return false 1379 - } 1380 - 1381 - // Check for common patch format markers 1382 - firstLine := strings.TrimSpace(lines[0]) 1383 - return strings.HasPrefix(firstLine, "diff ") || 1384 - strings.HasPrefix(firstLine, "--- ") || 1385 - strings.HasPrefix(firstLine, "Index: ") || 1386 - strings.HasPrefix(firstLine, "+++ ") || 1387 - strings.HasPrefix(firstLine, "@@ ") 1388 - }
+44 -15
appview/state/repo.go
··· 21 21 "github.com/bluesky-social/indigo/atproto/syntax" 22 22 securejoin "github.com/cyphar/filepath-securejoin" 23 23 "github.com/go-chi/chi/v5" 24 + "github.com/go-git/go-git/v5/plumbing" 24 25 "tangled.sh/tangled.sh/core/api/tangled" 26 + "tangled.sh/tangled.sh/core/appview" 25 27 "tangled.sh/tangled.sh/core/appview/auth" 26 28 "tangled.sh/tangled.sh/core/appview/db" 27 29 "tangled.sh/tangled.sh/core/appview/pages" 28 30 "tangled.sh/tangled.sh/core/appview/pages/markup" 31 + "tangled.sh/tangled.sh/core/appview/pagination" 29 32 "tangled.sh/tangled.sh/core/types" 30 33 31 34 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 248 251 if !s.config.Dev { 249 252 protocol = "https" 250 253 } 254 + 255 + if !plumbing.IsHash(ref) { 256 + s.pages.Error404(w) 257 + return 258 + } 259 + 251 260 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 252 261 if err != nil { 253 262 log.Println("failed to reach knotserver", err) ··· 312 321 user := s.auth.GetUser(r) 313 322 314 323 var breadcrumbs [][]string 315 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)}) 324 + breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 316 325 if treePath != "" { 317 326 for idx, elem := range strings.Split(treePath, "/") { 318 327 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 319 328 } 320 329 } 321 330 322 - baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath) 323 - baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath) 331 + baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 332 + baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 324 333 325 334 s.pages.RepoTree(w, pages.RepoTreeParams{ 326 335 LoggedInUser: user, ··· 447 456 } 448 457 449 458 var breadcrumbs [][]string 450 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)}) 459 + breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 451 460 if filePath != "" { 452 461 for idx, elem := range strings.Split(filePath, "/") { 453 462 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 577 586 } 578 587 }() 579 588 580 - err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo()) 589 + err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 581 590 if err != nil { 582 591 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 583 592 return ··· 670 679 }() 671 680 672 681 // remove collaborator RBAC 673 - repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot) 682 + repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 674 683 if err != nil { 675 684 s.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 676 685 return 677 686 } 678 687 for _, c := range repoCollaborators { 679 688 did := c[0] 680 - s.enforcer.RemoveCollaborator(did, f.Knot, f.OwnerSlashRepo()) 689 + s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 681 690 } 682 691 log.Println("removed collaborators") 683 692 684 693 // remove repo RBAC 685 - err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.OwnerSlashRepo()) 694 + err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 686 695 if err != nil { 687 696 s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 688 697 return ··· 770 779 771 780 isCollaboratorInviteAllowed := false 772 781 if user != nil { 773 - ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo()) 782 + ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 774 783 if err == nil && ok { 775 784 isCollaboratorInviteAllowed = true 776 785 } ··· 854 863 } 855 864 856 865 func (f *FullyResolvedRepo) OwnerSlashRepo() string { 866 + handle := f.OwnerId.Handle 867 + 868 + var p string 869 + if handle != "" && !handle.IsInvalidHandle() { 870 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 871 + } else { 872 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 873 + } 874 + 875 + return p 876 + } 877 + 878 + func (f *FullyResolvedRepo) DidSlashRepo() string { 857 879 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 858 880 return p 859 881 } 860 882 861 883 func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 862 - repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot) 884 + repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 863 885 if err != nil { 864 886 return nil, err 865 887 } ··· 1096 1118 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1097 1119 Collection: tangled.RepoIssueStateNSID, 1098 1120 Repo: user.Did, 1099 - Rkey: s.TID(), 1121 + Rkey: appview.TID(), 1100 1122 Record: &lexutil.LexiconTypeDecoder{ 1101 1123 Val: &tangled.RepoIssueState{ 1102 1124 Issue: issue.IssueAt, ··· 1200 1222 } 1201 1223 1202 1224 commentId := mathrand.IntN(1000000) 1203 - rkey := s.TID() 1225 + rkey := appview.TID() 1204 1226 1205 1227 err := db.NewIssueComment(s.db, &db.Comment{ 1206 1228 OwnerDid: user.Did, ··· 1538 1560 isOpen = true 1539 1561 } 1540 1562 1563 + page, ok := r.Context().Value("page").(pagination.Page) 1564 + if !ok { 1565 + log.Println("failed to get page") 1566 + page = pagination.FirstPage() 1567 + } 1568 + 1541 1569 user := s.auth.GetUser(r) 1542 1570 f, err := fullyResolvedRepo(r) 1543 1571 if err != nil { ··· 1545 1573 return 1546 1574 } 1547 1575 1548 - issues, err := db.GetIssues(s.db, f.RepoAt, isOpen) 1576 + issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page) 1549 1577 if err != nil { 1550 1578 log.Println("failed to get issues", err) 1551 1579 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 1572 1600 Issues: issues, 1573 1601 DidHandleMap: didHandleMap, 1574 1602 FilteringByOpen: isOpen, 1603 + Page: page, 1575 1604 }) 1576 1605 return 1577 1606 } ··· 1630 1659 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1631 1660 Collection: tangled.RepoIssueNSID, 1632 1661 Repo: user.Did, 1633 - Rkey: s.TID(), 1662 + Rkey: appview.TID(), 1634 1663 Record: &lexutil.LexiconTypeDecoder{ 1635 1664 Val: &tangled.RepoIssue{ 1636 1665 Repo: atUri, ··· 1734 1763 sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1735 1764 sourceAt := f.RepoAt.String() 1736 1765 1737 - rkey := s.TID() 1766 + rkey := appview.TID() 1738 1767 repo := &db.Repo{ 1739 1768 Did: user.Did, 1740 1769 Name: forkName,
+1 -1
appview/state/repo_util.go
··· 58 58 59 59 func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo { 60 60 if u != nil { 61 - r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo()) 61 + r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 62 62 return pages.RolesInRepo{r} 63 63 } else { 64 64 return pages.RolesInRepo{}
+28 -23
appview/state/router.go
··· 5 5 "strings" 6 6 7 7 "github.com/go-chi/chi/v5" 8 + "tangled.sh/tangled.sh/core/appview/middleware" 9 + "tangled.sh/tangled.sh/core/appview/settings" 8 10 "tangled.sh/tangled.sh/core/appview/state/userutil" 9 11 ) 10 12 ··· 66 68 r.Get("/blob/{ref}/raw/*", s.RepoBlobRaw) 67 69 68 70 r.Route("/issues", func(r chi.Router) { 69 - r.Get("/", s.RepoIssues) 71 + r.With(middleware.Paginate).Get("/", s.RepoIssues) 70 72 r.Get("/{issue}", s.RepoSingleIssue) 71 73 72 74 r.Group(func(r chi.Router) { 73 - r.Use(AuthMiddleware(s)) 75 + r.Use(middleware.AuthMiddleware(s.auth)) 74 76 r.Get("/new", s.NewIssue) 75 77 r.Post("/new", s.NewIssue) 76 78 r.Post("/{issue}/comment", s.NewIssueComment) ··· 86 88 }) 87 89 88 90 r.Route("/fork", func(r chi.Router) { 89 - r.Use(AuthMiddleware(s)) 91 + r.Use(middleware.AuthMiddleware(s.auth)) 90 92 r.Get("/", s.ForkRepo) 91 93 r.Post("/", s.ForkRepo) 92 94 }) 93 95 94 96 r.Route("/pulls", func(r chi.Router) { 95 97 r.Get("/", s.RepoPulls) 96 - r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) { 98 + r.With(middleware.AuthMiddleware(s.auth)).Route("/new", func(r chi.Router) { 97 99 r.Get("/", s.NewPull) 98 100 r.Get("/patch-upload", s.PatchUploadFragment) 101 + r.Post("/validate-patch", s.ValidatePatch) 99 102 r.Get("/compare-branches", s.CompareBranchesFragment) 100 103 r.Get("/compare-forks", s.CompareForksFragment) 101 104 r.Get("/fork-branches", s.CompareForksBranchesFragment) ··· 108 111 109 112 r.Route("/round/{round}", func(r chi.Router) { 110 113 r.Get("/", s.RepoPullPatch) 114 + r.Get("/interdiff", s.RepoPullInterdiff) 111 115 r.Get("/actions", s.PullActions) 112 - r.With(AuthMiddleware(s)).Route("/comment", func(r chi.Router) { 116 + r.With(middleware.AuthMiddleware(s.auth)).Route("/comment", func(r chi.Router) { 113 117 r.Get("/", s.PullComment) 114 118 r.Post("/", s.PullComment) 115 119 }) ··· 120 124 }) 121 125 122 126 r.Group(func(r chi.Router) { 123 - r.Use(AuthMiddleware(s)) 127 + r.Use(middleware.AuthMiddleware(s.auth)) 124 128 r.Route("/resubmit", func(r chi.Router) { 125 129 r.Get("/", s.ResubmitPull) 126 130 r.Post("/", s.ResubmitPull) ··· 143 147 144 148 // settings routes, needs auth 145 149 r.Group(func(r chi.Router) { 146 - r.Use(AuthMiddleware(s)) 150 + r.Use(middleware.AuthMiddleware(s.auth)) 147 151 // repo description can only be edited by owner 148 152 r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) { 149 153 r.Put("/", s.RepoDescription) ··· 174 178 175 179 r.Get("/", s.Timeline) 176 180 177 - r.With(AuthMiddleware(s)).Post("/logout", s.Logout) 181 + r.With(middleware.AuthMiddleware(s.auth)).Post("/logout", s.Logout) 178 182 179 183 r.Route("/login", func(r chi.Router) { 180 184 r.Get("/", s.Login) ··· 182 186 }) 183 187 184 188 r.Route("/knots", func(r chi.Router) { 185 - r.Use(AuthMiddleware(s)) 189 + r.Use(middleware.AuthMiddleware(s.auth)) 186 190 r.Get("/", s.Knots) 187 191 r.Post("/key", s.RegistrationKey) 188 192 ··· 200 204 201 205 r.Route("/repo", func(r chi.Router) { 202 206 r.Route("/new", func(r chi.Router) { 203 - r.Use(AuthMiddleware(s)) 207 + r.Use(middleware.AuthMiddleware(s.auth)) 204 208 r.Get("/", s.NewRepo) 205 209 r.Post("/", s.NewRepo) 206 210 }) 207 211 // r.Post("/import", s.ImportRepo) 208 212 }) 209 213 210 - r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) { 214 + r.With(middleware.AuthMiddleware(s.auth)).Route("/follow", func(r chi.Router) { 211 215 r.Post("/", s.Follow) 212 216 r.Delete("/", s.Follow) 213 217 }) 214 218 215 - r.With(AuthMiddleware(s)).Route("/star", func(r chi.Router) { 219 + r.With(middleware.AuthMiddleware(s.auth)).Route("/star", func(r chi.Router) { 216 220 r.Post("/", s.Star) 217 221 r.Delete("/", s.Star) 218 222 }) 219 223 220 - r.Route("/settings", func(r chi.Router) { 221 - r.Use(AuthMiddleware(s)) 222 - r.Get("/", s.Settings) 223 - r.Put("/keys", s.SettingsKeys) 224 - r.Delete("/keys", s.SettingsKeys) 225 - r.Put("/emails", s.SettingsEmails) 226 - r.Delete("/emails", s.SettingsEmails) 227 - r.Get("/emails/verify", s.SettingsEmailsVerify) 228 - r.Post("/emails/verify/resend", s.SettingsEmailsVerifyResend) 229 - r.Post("/emails/primary", s.SettingsEmailsPrimary) 230 - }) 224 + r.Mount("/settings", s.SettingsRouter()) 231 225 232 226 r.Get("/keys/{user}", s.Keys) 233 227 ··· 236 230 }) 237 231 return r 238 232 } 233 + 234 + func (s *State) SettingsRouter() http.Handler { 235 + settings := &settings.Settings{ 236 + Db: s.db, 237 + Auth: s.auth, 238 + Pages: s.pages, 239 + Config: s.config, 240 + } 241 + 242 + return settings.Router() 243 + }
-416
appview/state/settings.go
··· 1 - package state 2 - 3 - import ( 4 - "database/sql" 5 - "errors" 6 - "fmt" 7 - "log" 8 - "net/http" 9 - "net/url" 10 - "strings" 11 - "time" 12 - 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - "github.com/gliderlabs/ssh" 16 - "github.com/google/uuid" 17 - "tangled.sh/tangled.sh/core/api/tangled" 18 - "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/email" 20 - "tangled.sh/tangled.sh/core/appview/pages" 21 - ) 22 - 23 - func (s *State) Settings(w http.ResponseWriter, r *http.Request) { 24 - user := s.auth.GetUser(r) 25 - pubKeys, err := db.GetPublicKeys(s.db, user.Did) 26 - if err != nil { 27 - log.Println(err) 28 - } 29 - 30 - emails, err := db.GetAllEmails(s.db, user.Did) 31 - if err != nil { 32 - log.Println(err) 33 - } 34 - 35 - s.pages.Settings(w, pages.SettingsParams{ 36 - LoggedInUser: user, 37 - PubKeys: pubKeys, 38 - Emails: emails, 39 - }) 40 - } 41 - 42 - // buildVerificationEmail creates an email.Email struct for verification emails 43 - func (s *State) buildVerificationEmail(emailAddr, did, code string) email.Email { 44 - verifyURL := s.verifyUrl(did, emailAddr, code) 45 - 46 - return email.Email{ 47 - APIKey: s.config.ResendApiKey, 48 - From: "noreply@notifs.tangled.sh", 49 - To: emailAddr, 50 - Subject: "Verify your Tangled email", 51 - Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 52 - ` + verifyURL, 53 - Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p> 54 - <p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`, 55 - } 56 - } 57 - 58 - // sendVerificationEmail handles the common logic for sending verification emails 59 - func (s *State) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 60 - emailToSend := s.buildVerificationEmail(emailAddr, did, code) 61 - 62 - err := email.SendEmail(emailToSend) 63 - if err != nil { 64 - log.Printf("sending email: %s", err) 65 - s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 66 - return err 67 - } 68 - 69 - return nil 70 - } 71 - 72 - func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) { 73 - switch r.Method { 74 - case http.MethodGet: 75 - s.pages.Notice(w, "settings-emails", "Unimplemented.") 76 - log.Println("unimplemented") 77 - return 78 - case http.MethodPut: 79 - did := s.auth.GetDid(r) 80 - emAddr := r.FormValue("email") 81 - emAddr = strings.TrimSpace(emAddr) 82 - 83 - if !email.IsValidEmail(emAddr) { 84 - s.pages.Notice(w, "settings-emails-error", "Invalid email address.") 85 - return 86 - } 87 - 88 - // check if email already exists in database 89 - existingEmail, err := db.GetEmail(s.db, did, emAddr) 90 - if err != nil && !errors.Is(err, sql.ErrNoRows) { 91 - log.Printf("checking for existing email: %s", err) 92 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 93 - return 94 - } 95 - 96 - if err == nil { 97 - if existingEmail.Verified { 98 - s.pages.Notice(w, "settings-emails-error", "This email is already verified.") 99 - return 100 - } 101 - 102 - s.pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.") 103 - return 104 - } 105 - 106 - code := uuid.New().String() 107 - 108 - // Begin transaction 109 - tx, err := s.db.Begin() 110 - if err != nil { 111 - log.Printf("failed to start transaction: %s", err) 112 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 113 - return 114 - } 115 - defer tx.Rollback() 116 - 117 - if err := db.AddEmail(tx, db.Email{ 118 - Did: did, 119 - Address: emAddr, 120 - Verified: false, 121 - VerificationCode: code, 122 - }); err != nil { 123 - log.Printf("adding email: %s", err) 124 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 125 - return 126 - } 127 - 128 - if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 129 - return 130 - } 131 - 132 - // Commit transaction 133 - if err := tx.Commit(); err != nil { 134 - log.Printf("failed to commit transaction: %s", err) 135 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 136 - return 137 - } 138 - 139 - s.pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.") 140 - return 141 - case http.MethodDelete: 142 - did := s.auth.GetDid(r) 143 - emailAddr := r.FormValue("email") 144 - emailAddr = strings.TrimSpace(emailAddr) 145 - 146 - // Begin transaction 147 - tx, err := s.db.Begin() 148 - if err != nil { 149 - log.Printf("failed to start transaction: %s", err) 150 - s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 151 - return 152 - } 153 - defer tx.Rollback() 154 - 155 - if err := db.DeleteEmail(tx, did, emailAddr); err != nil { 156 - log.Printf("deleting email: %s", err) 157 - s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 158 - return 159 - } 160 - 161 - // Commit transaction 162 - if err := tx.Commit(); err != nil { 163 - log.Printf("failed to commit transaction: %s", err) 164 - s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 165 - return 166 - } 167 - 168 - s.pages.HxLocation(w, "/settings") 169 - return 170 - } 171 - } 172 - 173 - func (s *State) verifyUrl(did string, email string, code string) string { 174 - var appUrl string 175 - if s.config.Dev { 176 - appUrl = "http://" + s.config.ListenAddr 177 - } else { 178 - appUrl = "https://tangled.sh" 179 - } 180 - 181 - return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code)) 182 - } 183 - 184 - func (s *State) SettingsEmailsVerify(w http.ResponseWriter, r *http.Request) { 185 - q := r.URL.Query() 186 - 187 - // Get the parameters directly from the query 188 - emailAddr := q.Get("email") 189 - did := q.Get("did") 190 - code := q.Get("code") 191 - 192 - valid, err := db.CheckValidVerificationCode(s.db, did, emailAddr, code) 193 - if err != nil { 194 - log.Printf("checking email verification: %s", err) 195 - s.pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") 196 - return 197 - } 198 - 199 - if !valid { 200 - s.pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.") 201 - return 202 - } 203 - 204 - // Mark email as verified in the database 205 - if err := db.MarkEmailVerified(s.db, did, emailAddr); err != nil { 206 - log.Printf("marking email as verified: %s", err) 207 - s.pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") 208 - return 209 - } 210 - 211 - http.Redirect(w, r, "/settings", http.StatusSeeOther) 212 - } 213 - 214 - func (s *State) SettingsEmailsVerifyResend(w http.ResponseWriter, r *http.Request) { 215 - if r.Method != http.MethodPost { 216 - s.pages.Notice(w, "settings-emails-error", "Invalid request method.") 217 - return 218 - } 219 - 220 - did := s.auth.GetDid(r) 221 - emAddr := r.FormValue("email") 222 - emAddr = strings.TrimSpace(emAddr) 223 - 224 - if !email.IsValidEmail(emAddr) { 225 - s.pages.Notice(w, "settings-emails-error", "Invalid email address.") 226 - return 227 - } 228 - 229 - // Check if email exists and is unverified 230 - existingEmail, err := db.GetEmail(s.db, did, emAddr) 231 - if err != nil { 232 - if errors.Is(err, sql.ErrNoRows) { 233 - s.pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.") 234 - } else { 235 - log.Printf("checking for existing email: %s", err) 236 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 237 - } 238 - return 239 - } 240 - 241 - if existingEmail.Verified { 242 - s.pages.Notice(w, "settings-emails-error", "This email is already verified.") 243 - return 244 - } 245 - 246 - // Check if last verification email was sent less than 10 minutes ago 247 - if existingEmail.LastSent != nil { 248 - timeSinceLastSent := time.Since(*existingEmail.LastSent) 249 - if timeSinceLastSent < 10*time.Minute { 250 - waitTime := 10*time.Minute - timeSinceLastSent 251 - s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1))) 252 - return 253 - } 254 - } 255 - 256 - // Generate new verification code 257 - code := uuid.New().String() 258 - 259 - // Begin transaction 260 - tx, err := s.db.Begin() 261 - if err != nil { 262 - log.Printf("failed to start transaction: %s", err) 263 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 264 - return 265 - } 266 - defer tx.Rollback() 267 - 268 - // Update the verification code and last sent time 269 - if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil { 270 - log.Printf("updating email verification: %s", err) 271 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 272 - return 273 - } 274 - 275 - // Send verification email 276 - if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 277 - return 278 - } 279 - 280 - // Commit transaction 281 - if err := tx.Commit(); err != nil { 282 - log.Printf("failed to commit transaction: %s", err) 283 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 284 - return 285 - } 286 - 287 - s.pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.") 288 - } 289 - 290 - func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) { 291 - did := s.auth.GetDid(r) 292 - emailAddr := r.FormValue("email") 293 - emailAddr = strings.TrimSpace(emailAddr) 294 - 295 - if emailAddr == "" { 296 - s.pages.Notice(w, "settings-emails-error", "Email address cannot be empty.") 297 - return 298 - } 299 - 300 - if err := db.MakeEmailPrimary(s.db, did, emailAddr); err != nil { 301 - log.Printf("setting primary email: %s", err) 302 - s.pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") 303 - return 304 - } 305 - 306 - s.pages.HxLocation(w, "/settings") 307 - } 308 - 309 - func (s *State) SettingsKeys(w http.ResponseWriter, r *http.Request) { 310 - switch r.Method { 311 - case http.MethodGet: 312 - s.pages.Notice(w, "settings-keys", "Unimplemented.") 313 - log.Println("unimplemented") 314 - return 315 - case http.MethodPut: 316 - did := s.auth.GetDid(r) 317 - key := r.FormValue("key") 318 - key = strings.TrimSpace(key) 319 - name := r.FormValue("name") 320 - client, _ := s.auth.AuthorizedClient(r) 321 - 322 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) 323 - if err != nil { 324 - log.Printf("parsing public key: %s", err) 325 - s.pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 326 - return 327 - } 328 - 329 - rkey := s.TID() 330 - 331 - tx, err := s.db.Begin() 332 - if err != nil { 333 - log.Printf("failed to start tx; adding public key: %s", err) 334 - s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 335 - return 336 - } 337 - defer tx.Rollback() 338 - 339 - if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil { 340 - log.Printf("adding public key: %s", err) 341 - s.pages.Notice(w, "settings-keys", "Failed to add public key.") 342 - return 343 - } 344 - 345 - // store in pds too 346 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 347 - Collection: tangled.PublicKeyNSID, 348 - Repo: did, 349 - Rkey: rkey, 350 - Record: &lexutil.LexiconTypeDecoder{ 351 - Val: &tangled.PublicKey{ 352 - Created: time.Now().Format(time.RFC3339), 353 - Key: key, 354 - Name: name, 355 - }}, 356 - }) 357 - // invalid record 358 - if err != nil { 359 - log.Printf("failed to create record: %s", err) 360 - s.pages.Notice(w, "settings-keys", "Failed to create record.") 361 - return 362 - } 363 - 364 - log.Println("created atproto record: ", resp.Uri) 365 - 366 - err = tx.Commit() 367 - if err != nil { 368 - log.Printf("failed to commit tx; adding public key: %s", err) 369 - s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 370 - return 371 - } 372 - 373 - s.pages.HxLocation(w, "/settings") 374 - return 375 - 376 - case http.MethodDelete: 377 - did := s.auth.GetDid(r) 378 - q := r.URL.Query() 379 - 380 - name := q.Get("name") 381 - rkey := q.Get("rkey") 382 - key := q.Get("key") 383 - 384 - log.Println(name) 385 - log.Println(rkey) 386 - log.Println(key) 387 - 388 - client, _ := s.auth.AuthorizedClient(r) 389 - 390 - if err := db.RemovePublicKey(s.db, did, name, key); err != nil { 391 - log.Printf("removing public key: %s", err) 392 - s.pages.Notice(w, "settings-keys", "Failed to remove public key.") 393 - return 394 - } 395 - 396 - if rkey != "" { 397 - // remove from pds too 398 - _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 399 - Collection: tangled.PublicKeyNSID, 400 - Repo: did, 401 - Rkey: rkey, 402 - }) 403 - 404 - // invalid record 405 - if err != nil { 406 - log.Printf("failed to delete record from PDS: %s", err) 407 - s.pages.Notice(w, "settings-keys", "Failed to remove key from PDS.") 408 - return 409 - } 410 - } 411 - log.Println("deleted successfully") 412 - 413 - s.pages.HxLocation(w, "/settings") 414 - return 415 - } 416 - }
+31 -3
appview/state/signer.go
··· 7 7 "encoding/hex" 8 8 "encoding/json" 9 9 "fmt" 10 + "io" 11 + "log" 10 12 "net/http" 11 13 "net/url" 12 14 "time" ··· 376 378 return &capabilities, nil 377 379 } 378 380 379 - func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*http.Response, error) { 381 + func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) { 380 382 const ( 381 383 Method = "GET" 382 384 ) ··· 385 387 386 388 req, err := us.newRequest(Method, endpoint, nil) 387 389 if err != nil { 388 - return nil, err 390 + return nil, fmt.Errorf("Failed to create request.") 389 391 } 390 392 391 - return us.client.Do(req) 393 + compareResp, err := us.client.Do(req) 394 + if err != nil { 395 + return nil, fmt.Errorf("Failed to create request.") 396 + } 397 + defer compareResp.Body.Close() 398 + 399 + switch compareResp.StatusCode { 400 + case 404: 401 + case 400: 402 + return nil, fmt.Errorf("Branch comparisons not supported on this knot.") 403 + } 404 + 405 + respBody, err := io.ReadAll(compareResp.Body) 406 + if err != nil { 407 + log.Println("failed to compare across branches") 408 + return nil, fmt.Errorf("Failed to compare branches.") 409 + } 410 + defer compareResp.Body.Close() 411 + 412 + var formatPatchResponse types.RepoFormatPatchResponse 413 + err = json.Unmarshal(respBody, &formatPatchResponse) 414 + if err != nil { 415 + log.Println("failed to unmarshal format-patch response", err) 416 + return nil, fmt.Errorf("failed to compare branches.") 417 + } 418 + 419 + return &formatPatchResponse, nil 392 420 }
+2 -1
appview/state/star.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 11 tangled "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/appview" 12 13 "tangled.sh/tangled.sh/core/appview/db" 13 14 "tangled.sh/tangled.sh/core/appview/pages" 14 15 ) ··· 33 34 switch r.Method { 34 35 case http.MethodPost: 35 36 createdAt := time.Now().Format(time.RFC3339) 36 - rkey := s.TID() 37 + rkey := appview.TID() 37 38 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 38 39 Collection: tangled.FeedStarNSID, 39 40 Repo: currentUser.Did,
+5 -5
appview/state/state.go
··· 55 55 56 56 clock := syntax.NewTIDClock(0) 57 57 58 - pgs := pages.NewPages() 58 + pgs := pages.NewPages(config.Dev) 59 59 60 60 resolver := appview.NewResolver() 61 61 ··· 91 91 return state, nil 92 92 } 93 93 94 - func (s *State) TID() string { 95 - return s.tidClock.Next().String() 94 + func TID(c *syntax.TIDClock) string { 95 + return c.Next().String() 96 96 } 97 97 98 98 func (s *State) Login(w http.ResponseWriter, r *http.Request) { ··· 522 522 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 523 523 Collection: tangled.KnotMemberNSID, 524 524 Repo: currentUser.Did, 525 - Rkey: s.TID(), 525 + Rkey: appview.TID(), 526 526 Record: &lexutil.LexiconTypeDecoder{ 527 527 Val: &tangled.KnotMember{ 528 528 Member: memberIdent.DID.String(), ··· 646 646 return 647 647 } 648 648 649 - rkey := s.TID() 649 + rkey := appview.TID() 650 650 repo := &db.Repo{ 651 651 Did: user.Did, 652 652 Name: repoName,
+11
appview/tid.go
··· 1 + package appview 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/syntax" 5 + ) 6 + 7 + var c *syntax.TIDClock = syntax.NewTIDClock(0) 8 + 9 + func TID() string { 10 + return c.Next().String() 11 + }
+38
cmd/combinediff/main.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.sh/tangled.sh/core/patchutil" 9 + ) 10 + 11 + func main() { 12 + if len(os.Args) != 3 { 13 + fmt.Println("Usage: combinediff <patch1> <patch2>") 14 + os.Exit(1) 15 + } 16 + 17 + patch1, err := os.Open(os.Args[1]) 18 + if err != nil { 19 + fmt.Println(err) 20 + } 21 + patch2, err := os.Open(os.Args[2]) 22 + if err != nil { 23 + fmt.Println(err) 24 + } 25 + 26 + files1, _, err := gitdiff.Parse(patch1) 27 + if err != nil { 28 + fmt.Println(err) 29 + } 30 + 31 + files2, _, err := gitdiff.Parse(patch2) 32 + if err != nil { 33 + fmt.Println(err) 34 + } 35 + 36 + combined := patchutil.CombineDiff(files1, files2) 37 + fmt.Println(combined) 38 + }
+38
cmd/interdiff/main.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.sh/tangled.sh/core/patchutil" 9 + ) 10 + 11 + func main() { 12 + if len(os.Args) != 3 { 13 + fmt.Println("Usage: interdiff <patch1> <patch2>") 14 + os.Exit(1) 15 + } 16 + 17 + patch1, err := os.Open(os.Args[1]) 18 + if err != nil { 19 + fmt.Println(err) 20 + } 21 + patch2, err := os.Open(os.Args[2]) 22 + if err != nil { 23 + fmt.Println(err) 24 + } 25 + 26 + files1, _, err := gitdiff.Parse(patch1) 27 + if err != nil { 28 + fmt.Println(err) 29 + } 30 + 31 + files2, _, err := gitdiff.Parse(patch2) 32 + if err != nil { 33 + fmt.Println(err) 34 + } 35 + 36 + interDiffResult := patchutil.Interdiff(files1, files2) 37 + fmt.Println(interDiffResult) 38 + }
+2 -2
docker/docker-compose.yml
··· 4 4 context: .. 5 5 dockerfile: docker/Dockerfile 6 6 environment: 7 - KNOT_SERVER_HOSTNAME: "knot.example.org" 8 - KNOT_SERVER_SECRET: "secret" 7 + KNOT_SERVER_HOSTNAME: ${KNOT_SERVER_HOSTNAME} 8 + KNOT_SERVER_SECRET: ${KNOT_SERVER_SECRET} 9 9 KNOT_SERVER_DB_PATH: "/app/knotserver.db" 10 10 KNOT_REPO_SCAN_PATH: "/home/git/repositories" 11 11 volumes:
+9 -7
docs/contributing.md
··· 11 11 ### message format 12 12 13 13 ``` 14 - <service/top-level directory>: <package/path>: <short summary of change> 14 + <service/top-level directory>: <affected package/directory>: <short summary of change> 15 15 16 16 17 - Optional longer description, if needed. Explain what the change does and 18 - why, especially if not obvious. Reference relevant issues or PRs when 19 - applicable. These can be links for now since we don't auto-link 20 - issues/PRs yet. 17 + Optional longer description can go here, if necessary. Explain what the 18 + change does and why, especially if not obvious. Reference relevant 19 + issues or PRs when applicable. These can be links for now since we don't 20 + auto-link issues/PRs yet. 21 21 ``` 22 22 23 23 Here are some examples: ··· 35 35 36 36 ### general notes 37 37 38 - - PRs get merged as a single commit, so keep PRs small and focused. Use 39 - the above guidelines for the PR title and description. 38 + - PRs get merged "as-is" (fast-forward) -- like applying a patch-series 39 + using `git am`. At present, there is no squashing -- so please author 40 + your commits as they would appear on `master`, following the above 41 + guidelines. 40 42 - Use the imperative mood in the summary line (e.g., "fix bug" not 41 43 "fixed bug" or "fixes bug"). 42 44 - Try to keep the summary line under 72 characters, but we aren't too
+1 -1
docs/knot-hosting.md
··· 26 26 docker compose -f docker/docker-compose.yml up 27 27 ``` 28 28 29 - ### manual setup 29 + ## manual setup 30 30 31 31 First, clone this repository: 32 32
+3 -3
flake.lock
··· 48 48 "indigo": { 49 49 "flake": false, 50 50 "locked": { 51 - "lastModified": 1738491661, 52 - "narHash": "sha256-+njDigkvjH4XmXZMog5Mp0K4x9mamHX6gSGJCZB9mE4=", 51 + "lastModified": 1745333930, 52 + "narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=", 53 53 "owner": "oppiliappan", 54 54 "repo": "indigo", 55 - "rev": "feb802f02a462ac0a6392ffc3e40b0529f0cdf71", 55 + "rev": "e4e59280737b8676611fc077a228d47b3e8e9491", 56 56 "type": "github" 57 57 }, 58 58 "original": {
+12 -3
flake.nix
··· 49 49 inherit (gitignore.lib) gitignoreSource; 50 50 in { 51 51 overlays.default = final: prev: let 52 - goModHash = "sha256-2vljseczrvsl2T0P9k69ro72yU59l5fp9r/sszmXYY4="; 52 + goModHash = "sha256-EilWxfqrcKDaSR5zA3ZuDSCq7V+/IfWpKPu8HWhpndA="; 53 53 buildCmdPackage = name: 54 54 final.buildGoModule { 55 55 pname = name; ··· 173 173 ${pkgs.air}/bin/air -c /dev/null \ 174 174 -build.cmd "${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o ./appview/pages/static/tw.css && ${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 175 175 -build.bin "./out/${name}.out" \ 176 - -build.include_ext "go,html,css" 176 + -build.include_ext "go" 177 + ''; 178 + tailwind-watcher = 179 + pkgs.writeShellScriptBin "run" 180 + '' 181 + ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 177 182 ''; 178 183 in { 179 184 watch-appview = { ··· 183 188 watch-knotserver = { 184 189 type = "app"; 185 190 program = ''${air-watcher "knotserver"}/bin/run''; 191 + }; 192 + watch-tailwind = { 193 + type = "app"; 194 + program = ''${tailwind-watcher}/bin/run''; 186 195 }; 187 196 }); 188 197 ··· 420 429 g = config.services.tangled-knotserver.gitUser; 421 430 in [ 422 431 "d /var/lib/knotserver 0770 ${u} ${g} - -" # Create the directory first 423 - "f+ /var/lib/knotserver/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=6995e040e80e2d593b5e5e9ca611a70140b9ef8044add0a28b48b1ee34aa3e85" 432 + "f+ /var/lib/knotserver/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=5b42390da4c6659f34c9a545adebd8af82c4a19960d735f651e3d582623ba9f2" 424 433 ]; 425 434 services.tangled-knotserver = { 426 435 enable = true;
+3 -3
go.mod
··· 106 106 go.uber.org/atomic v1.11.0 // indirect 107 107 go.uber.org/multierr v1.11.0 // indirect 108 108 go.uber.org/zap v1.26.0 // indirect 109 - golang.org/x/crypto v0.36.0 // indirect 110 - golang.org/x/net v0.37.0 // indirect 111 - golang.org/x/sys v0.31.0 // indirect 109 + golang.org/x/crypto v0.37.0 // indirect 110 + golang.org/x/net v0.39.0 // indirect 111 + golang.org/x/sys v0.32.0 // indirect 112 112 golang.org/x/time v0.5.0 // indirect 113 113 google.golang.org/protobuf v1.34.2 // indirect 114 114 gopkg.in/warnings.v0 v0.1.2 // indirect
+10 -10
go.sum
··· 303 303 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 304 304 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 305 305 golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 306 - golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 307 - golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 306 + golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 307 + golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 308 308 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 309 309 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 310 310 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= ··· 327 327 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 328 328 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 329 329 golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 330 - golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 331 - golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 330 + golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 331 + golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 332 332 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 333 333 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 334 334 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 357 357 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 358 358 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 359 359 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 360 - golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 361 - golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 360 + golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 361 + golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 362 362 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 363 363 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 364 364 golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 365 365 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 366 366 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 367 - golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 368 - golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 367 + golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 368 + golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 369 369 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 370 370 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 371 371 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 372 372 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 373 373 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 374 374 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 375 - golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 376 - golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 375 + golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 376 + golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 377 377 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 378 378 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 379 379 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+168 -5
input.css
··· 35 35 font-size: 15px; 36 36 } 37 37 @supports (font-variation-settings: normal) { 38 - html { 39 - font-feature-settings: 'ss01' 1, 'kern' 1, 'liga' 1, 'cv05' 1, 'tnum' 1; 40 - } 38 + html { 39 + font-feature-settings: 40 + "ss01" 1, 41 + "kern" 1, 42 + "liga" 1, 43 + "cv05" 1, 44 + "tnum" 1; 45 + } 41 46 } 42 47 43 48 a { ··· 48 53 @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 49 54 } 50 55 input { 51 - @apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; 56 + @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; 52 57 } 53 58 textarea { 54 - @apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; 59 + @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; 55 60 } 56 61 details summary::-webkit-details-marker { 57 62 display: none; ··· 90 95 } 91 96 } 92 97 } 98 + 99 + /* Background */ .bg { color: #4c4f69; background-color: #eff1f5; } 100 + /* PreWrapper */ .chroma { color: #4c4f69; background-color: #eff1f5; } 101 + /* Error */ .chroma .err { color: #d20f39 } 102 + /* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit } 103 + /* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } 104 + /* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } 105 + /* LineHighlight */ .chroma .hl { background-color: #bcc0cc } 106 + /* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8c8fa1 } 107 + /* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8c8fa1 } 108 + /* Line */ .chroma .line { display: flex; } 109 + /* Keyword */ .chroma .k { color: #8839ef } 110 + /* KeywordConstant */ .chroma .kc { color: #fe640b } 111 + /* KeywordDeclaration */ .chroma .kd { color: #d20f39 } 112 + /* KeywordNamespace */ .chroma .kn { color: #179299 } 113 + /* KeywordPseudo */ .chroma .kp { color: #8839ef } 114 + /* KeywordReserved */ .chroma .kr { color: #8839ef } 115 + /* KeywordType */ .chroma .kt { color: #d20f39 } 116 + /* NameAttribute */ .chroma .na { color: #1e66f5 } 117 + /* NameBuiltin */ .chroma .nb { color: #04a5e5 } 118 + /* NameBuiltinPseudo */ .chroma .bp { color: #04a5e5 } 119 + /* NameClass */ .chroma .nc { color: #df8e1d } 120 + /* NameConstant */ .chroma .no { color: #df8e1d } 121 + /* NameDecorator */ .chroma .nd { color: #1e66f5; font-weight: bold } 122 + /* NameEntity */ .chroma .ni { color: #179299 } 123 + /* NameException */ .chroma .ne { color: #fe640b } 124 + /* NameFunction */ .chroma .nf { color: #1e66f5 } 125 + /* NameFunctionMagic */ .chroma .fm { color: #1e66f5 } 126 + /* NameLabel */ .chroma .nl { color: #04a5e5 } 127 + /* NameNamespace */ .chroma .nn { color: #fe640b } 128 + /* NameProperty */ .chroma .py { color: #fe640b } 129 + /* NameTag */ .chroma .nt { color: #8839ef } 130 + /* NameVariable */ .chroma .nv { color: #dc8a78 } 131 + /* NameVariableClass */ .chroma .vc { color: #dc8a78 } 132 + /* NameVariableGlobal */ .chroma .vg { color: #dc8a78 } 133 + /* NameVariableInstance */ .chroma .vi { color: #dc8a78 } 134 + /* NameVariableMagic */ .chroma .vm { color: #dc8a78 } 135 + /* LiteralString */ .chroma .s { color: #40a02b } 136 + /* LiteralStringAffix */ .chroma .sa { color: #d20f39 } 137 + /* LiteralStringBacktick */ .chroma .sb { color: #40a02b } 138 + /* LiteralStringChar */ .chroma .sc { color: #40a02b } 139 + /* LiteralStringDelimiter */ .chroma .dl { color: #1e66f5 } 140 + /* LiteralStringDoc */ .chroma .sd { color: #9ca0b0 } 141 + /* LiteralStringDouble */ .chroma .s2 { color: #40a02b } 142 + /* LiteralStringEscape */ .chroma .se { color: #1e66f5 } 143 + /* LiteralStringHeredoc */ .chroma .sh { color: #9ca0b0 } 144 + /* LiteralStringInterpol */ .chroma .si { color: #40a02b } 145 + /* LiteralStringOther */ .chroma .sx { color: #40a02b } 146 + /* LiteralStringRegex */ .chroma .sr { color: #179299 } 147 + /* LiteralStringSingle */ .chroma .s1 { color: #40a02b } 148 + /* LiteralStringSymbol */ .chroma .ss { color: #40a02b } 149 + /* LiteralNumber */ .chroma .m { color: #fe640b } 150 + /* LiteralNumberBin */ .chroma .mb { color: #fe640b } 151 + /* LiteralNumberFloat */ .chroma .mf { color: #fe640b } 152 + /* LiteralNumberHex */ .chroma .mh { color: #fe640b } 153 + /* LiteralNumberInteger */ .chroma .mi { color: #fe640b } 154 + /* LiteralNumberIntegerLong */ .chroma .il { color: #fe640b } 155 + /* LiteralNumberOct */ .chroma .mo { color: #fe640b } 156 + /* Operator */ .chroma .o { color: #04a5e5; font-weight: bold } 157 + /* OperatorWord */ .chroma .ow { color: #04a5e5; font-weight: bold } 158 + /* Comment */ .chroma .c { color: #9ca0b0; font-style: italic } 159 + /* CommentHashbang */ .chroma .ch { color: #9ca0b0; font-style: italic } 160 + /* CommentMultiline */ .chroma .cm { color: #9ca0b0; font-style: italic } 161 + /* CommentSingle */ .chroma .c1 { color: #9ca0b0; font-style: italic } 162 + /* CommentSpecial */ .chroma .cs { color: #9ca0b0; font-style: italic } 163 + /* CommentPreproc */ .chroma .cp { color: #9ca0b0; font-style: italic } 164 + /* CommentPreprocFile */ .chroma .cpf { color: #9ca0b0; font-weight: bold; font-style: italic } 165 + /* GenericDeleted */ .chroma .gd { color: #d20f39; background-color: oklch(93.6% 0.032 17.717) } 166 + /* GenericEmph */ .chroma .ge { font-style: italic } 167 + /* GenericError */ .chroma .gr { color: #d20f39 } 168 + /* GenericHeading */ .chroma .gh { color: #fe640b; font-weight: bold } 169 + /* GenericInserted */ .chroma .gi { color: #40a02b; background-color: oklch(96.2% 0.044 156.743) } 170 + /* GenericStrong */ .chroma .gs { font-weight: bold } 171 + /* GenericSubheading */ .chroma .gu { color: #fe640b; font-weight: bold } 172 + /* GenericTraceback */ .chroma .gt { color: #d20f39 } 173 + /* GenericUnderline */ .chroma .gl { text-decoration: underline } 174 + 175 + @media (prefers-color-scheme: dark) { 176 + /* Background */ .bg { color: #cad3f5; background-color: #24273a; } 177 + /* PreWrapper */ .chroma { color: #cad3f5; background-color: #24273a; } 178 + /* Error */ .chroma .err { color: #ed8796 } 179 + /* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit } 180 + /* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } 181 + /* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } 182 + /* LineHighlight */ .chroma .hl { background-color: #494d64 } 183 + /* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8087a2 } 184 + /* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8087a2 } 185 + /* Line */ .chroma .line { display: flex; } 186 + /* Keyword */ .chroma .k { color: #c6a0f6 } 187 + /* KeywordConstant */ .chroma .kc { color: #f5a97f } 188 + /* KeywordDeclaration */ .chroma .kd { color: #ed8796 } 189 + /* KeywordNamespace */ .chroma .kn { color: #8bd5ca } 190 + /* KeywordPseudo */ .chroma .kp { color: #c6a0f6 } 191 + /* KeywordReserved */ .chroma .kr { color: #c6a0f6 } 192 + /* KeywordType */ .chroma .kt { color: #ed8796 } 193 + /* NameAttribute */ .chroma .na { color: #8aadf4 } 194 + /* NameBuiltin */ .chroma .nb { color: #91d7e3 } 195 + /* NameBuiltinPseudo */ .chroma .bp { color: #91d7e3 } 196 + /* NameClass */ .chroma .nc { color: #eed49f } 197 + /* NameConstant */ .chroma .no { color: #eed49f } 198 + /* NameDecorator */ .chroma .nd { color: #8aadf4; font-weight: bold } 199 + /* NameEntity */ .chroma .ni { color: #8bd5ca } 200 + /* NameException */ .chroma .ne { color: #f5a97f } 201 + /* NameFunction */ .chroma .nf { color: #8aadf4 } 202 + /* NameFunctionMagic */ .chroma .fm { color: #8aadf4 } 203 + /* NameLabel */ .chroma .nl { color: #91d7e3 } 204 + /* NameNamespace */ .chroma .nn { color: #f5a97f } 205 + /* NameProperty */ .chroma .py { color: #f5a97f } 206 + /* NameTag */ .chroma .nt { color: #c6a0f6 } 207 + /* NameVariable */ .chroma .nv { color: #f4dbd6 } 208 + /* NameVariableClass */ .chroma .vc { color: #f4dbd6 } 209 + /* NameVariableGlobal */ .chroma .vg { color: #f4dbd6 } 210 + /* NameVariableInstance */ .chroma .vi { color: #f4dbd6 } 211 + /* NameVariableMagic */ .chroma .vm { color: #f4dbd6 } 212 + /* LiteralString */ .chroma .s { color: #a6da95 } 213 + /* LiteralStringAffix */ .chroma .sa { color: #ed8796 } 214 + /* LiteralStringBacktick */ .chroma .sb { color: #a6da95 } 215 + /* LiteralStringChar */ .chroma .sc { color: #a6da95 } 216 + /* LiteralStringDelimiter */ .chroma .dl { color: #8aadf4 } 217 + /* LiteralStringDoc */ .chroma .sd { color: #6e738d } 218 + /* LiteralStringDouble */ .chroma .s2 { color: #a6da95 } 219 + /* LiteralStringEscape */ .chroma .se { color: #8aadf4 } 220 + /* LiteralStringHeredoc */ .chroma .sh { color: #6e738d } 221 + /* LiteralStringInterpol */ .chroma .si { color: #a6da95 } 222 + /* LiteralStringOther */ .chroma .sx { color: #a6da95 } 223 + /* LiteralStringRegex */ .chroma .sr { color: #8bd5ca } 224 + /* LiteralStringSingle */ .chroma .s1 { color: #a6da95 } 225 + /* LiteralStringSymbol */ .chroma .ss { color: #a6da95 } 226 + /* LiteralNumber */ .chroma .m { color: #f5a97f } 227 + /* LiteralNumberBin */ .chroma .mb { color: #f5a97f } 228 + /* LiteralNumberFloat */ .chroma .mf { color: #f5a97f } 229 + /* LiteralNumberHex */ .chroma .mh { color: #f5a97f } 230 + /* LiteralNumberInteger */ .chroma .mi { color: #f5a97f } 231 + /* LiteralNumberIntegerLong */ .chroma .il { color: #f5a97f } 232 + /* LiteralNumberOct */ .chroma .mo { color: #f5a97f } 233 + /* Operator */ .chroma .o { color: #91d7e3; font-weight: bold } 234 + /* OperatorWord */ .chroma .ow { color: #91d7e3; font-weight: bold } 235 + /* Comment */ .chroma .c { color: #6e738d; font-style: italic } 236 + /* CommentHashbang */ .chroma .ch { color: #6e738d; font-style: italic } 237 + /* CommentMultiline */ .chroma .cm { color: #6e738d; font-style: italic } 238 + /* CommentSingle */ .chroma .c1 { color: #6e738d; font-style: italic } 239 + /* CommentSpecial */ .chroma .cs { color: #6e738d; font-style: italic } 240 + /* CommentPreproc */ .chroma .cp { color: #6e738d; font-style: italic } 241 + /* CommentPreprocFile */ .chroma .cpf { color: #6e738d; font-weight: bold; font-style: italic } 242 + /* GenericDeleted */ .chroma .gd { color: #ed8796; background-color: oklch(44.4% 0.177 26.899 / 0.5) } 243 + /* GenericEmph */ .chroma .ge { font-style: italic } 244 + /* GenericError */ .chroma .gr { color: #ed8796 } 245 + /* GenericHeading */ .chroma .gh { color: #f5a97f; font-weight: bold } 246 + /* GenericInserted */ .chroma .gi { color: #a6da95; background-color: oklch(44.8% 0.119 151.328 / 0.5) } 247 + /* GenericStrong */ .chroma .gs { font-weight: bold } 248 + /* GenericSubheading */ .chroma .gu { color: #f5a97f; font-weight: bold } 249 + /* GenericTraceback */ .chroma .gt { color: #ed8796 } 250 + /* GenericUnderline */ .chroma .gl { text-decoration: underline } 251 + } 252 + 253 + .chroma .line:has(.ln:target) { 254 + @apply bg-amber-400/30 dark:bg-amber-500/20 255 + }
+31
knotserver/git/diff.go
··· 1 1 package git 2 2 3 3 import ( 4 + "bytes" 4 5 "fmt" 5 6 "log" 7 + "os" 8 + "os/exec" 6 9 "strings" 7 10 8 11 "github.com/bluekeyes/go-gitdiff/gitdiff" 9 12 "github.com/go-git/go-git/v5/plumbing" 10 13 "github.com/go-git/go-git/v5/plumbing/object" 14 + "tangled.sh/tangled.sh/core/patchutil" 11 15 "tangled.sh/tangled.sh/core/types" 12 16 ) 13 17 ··· 118 122 Patch: patch.String(), 119 123 Diff: diffs, 120 124 }, nil 125 + } 126 + 127 + // FormatPatch generates a git-format-patch output between two commits, 128 + // and returns the raw format-patch series, a parsed FormatPatch and an error. 129 + func (g *GitRepo) FormatPatch(base, commit2 *object.Commit) (string, []patchutil.FormatPatch, error) { 130 + var stdout bytes.Buffer 131 + cmd := exec.Command( 132 + "git", 133 + "-C", 134 + g.path, 135 + "format-patch", 136 + fmt.Sprintf("%s..%s", base.Hash.String(), commit2.Hash.String()), 137 + "--stdout", 138 + ) 139 + cmd.Stdout = &stdout 140 + cmd.Stderr = os.Stderr 141 + err := cmd.Run() 142 + if err != nil { 143 + return "", nil, err 144 + } 145 + 146 + formatPatch, err := patchutil.ExtractPatches(stdout.String()) 147 + if err != nil { 148 + return "", nil, err 149 + } 150 + 151 + return stdout.String(), formatPatch, nil 121 152 } 122 153 123 154 func (g *GitRepo) MergeBase(commit1, commit2 *object.Commit) (*object.Commit, error) {
+17 -2
knotserver/git/merge.go
··· 10 10 11 11 "github.com/go-git/go-git/v5" 12 12 "github.com/go-git/go-git/v5/plumbing" 13 + "tangled.sh/tangled.sh/core/patchutil" 13 14 ) 14 15 15 16 type ErrMerge struct { ··· 30 31 CommitBody string 31 32 AuthorName string 32 33 AuthorEmail string 34 + FormatPatch bool 33 35 } 34 36 35 37 func (e ErrMerge) Error() string { ··· 89 91 if checkOnly { 90 92 cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 91 93 } else { 92 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 94 + // if patch is a format-patch, apply using 'git am' 95 + if opts.FormatPatch { 96 + amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile) 97 + amCmd.Stderr = &stderr 98 + if err := amCmd.Run(); err != nil { 99 + return fmt.Errorf("patch application failed: %s", stderr.String()) 100 + } 101 + return nil 102 + } 93 103 104 + // else, apply using 'git apply' and commit it manually 105 + exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 94 106 if opts != nil { 95 107 applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 96 108 applyCmd.Stderr = &stderr ··· 153 165 } 154 166 155 167 func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 168 + var opts MergeOptions 169 + opts.FormatPatch = patchutil.IsFormatPatch(string(patchData)) 170 + 156 171 patchFile, err := g.createTempFileWithPatch(patchData) 157 172 if err != nil { 158 173 return &ErrMerge{ ··· 171 186 } 172 187 defer os.RemoveAll(tmpDir) 173 188 174 - return g.applyPatch(tmpDir, patchFile, true, nil) 189 + return g.applyPatch(tmpDir, patchFile, true, &opts) 175 190 } 176 191 177 192 func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
+12 -2
knotserver/routes.go
··· 24 24 "github.com/go-git/go-git/v5/plumbing/object" 25 25 "tangled.sh/tangled.sh/core/knotserver/db" 26 26 "tangled.sh/tangled.sh/core/knotserver/git" 27 + "tangled.sh/tangled.sh/core/patchutil" 27 28 "tangled.sh/tangled.sh/core/types" 28 29 ) 29 30 ··· 36 37 37 38 capabilities := map[string]any{ 38 39 "pull_requests": map[string]any{ 40 + "format_patch": true, 39 41 "patch_submissions": true, 40 42 "branch_submissions": true, 41 43 "fork_submissions": true, ··· 687 689 notFound(w) 688 690 return 689 691 } 692 + 693 + mo.FormatPatch = patchutil.IsFormatPatch(patch) 694 + 690 695 if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 691 696 var mergeErr *git.ErrMerge 692 697 if errors.As(err, &mergeErr) { ··· 804 809 return 805 810 } 806 811 807 - difftree, err := gr.DiffTree(mergeBase, commit2) 812 + rawPatch, formatPatch, err := gr.FormatPatch(mergeBase, commit2) 808 813 if err != nil { 809 814 l.Error("error comparing revisions", "msg", err.Error()) 810 815 writeError(w, "error comparing revisions", http.StatusBadRequest) 811 816 return 812 817 } 813 818 814 - writeJSON(w, types.RepoDiffTreeResponse{difftree}) 819 + writeJSON(w, types.RepoFormatPatchResponse{ 820 + Rev1: commit1.Hash.String(), 821 + Rev2: commit2.Hash.String(), 822 + FormatPatch: formatPatch, 823 + Patch: rawPatch, 824 + }) 815 825 return 816 826 } 817 827
+168
patchutil/combinediff.go
··· 1 + package patchutil 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + ) 9 + 10 + // original1 -> patch1 -> rev1 11 + // original2 -> patch2 -> rev2 12 + // 13 + // original2 must be equal to rev1, so we can merge them to get maximal context 14 + // 15 + // finally, 16 + // rev2' <- apply(patch2, merged) 17 + // combineddiff <- diff(rev2', original1) 18 + func combineFiles(file1, file2 *gitdiff.File) (*gitdiff.File, error) { 19 + fileName := bestName(file1) 20 + 21 + o1 := CreatePreImage(file1) 22 + r1 := CreatePostImage(file1) 23 + o2 := CreatePreImage(file2) 24 + 25 + merged, err := r1.Merge(&o2) 26 + if err != nil { 27 + return nil, err 28 + } 29 + 30 + r2Prime, err := merged.Apply(file2) 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + // produce combined diff 36 + diff, err := Unified(o1.String(), fileName, r2Prime, fileName) 37 + if err != nil { 38 + return nil, err 39 + } 40 + 41 + parsed, _, err := gitdiff.Parse(strings.NewReader(diff)) 42 + 43 + if len(parsed) != 1 { 44 + // no diff? the second commit reverted the changes from the first 45 + return nil, nil 46 + } 47 + 48 + return parsed[0], nil 49 + } 50 + 51 + // use empty lines for lines we are unaware of 52 + // 53 + // this raises an error only if the two patches were invalid or non-contiguous 54 + func mergeLines(old, new string) (string, error) { 55 + var i, j int 56 + 57 + // TODO: use strings.Lines 58 + linesOld := strings.Split(old, "\n") 59 + linesNew := strings.Split(new, "\n") 60 + 61 + result := []string{} 62 + 63 + for i < len(linesOld) || j < len(linesNew) { 64 + if i >= len(linesOld) { 65 + // rest of the file is populated from `new` 66 + result = append(result, linesNew[j]) 67 + j++ 68 + continue 69 + } 70 + 71 + if j >= len(linesNew) { 72 + // rest of the file is populated from `old` 73 + result = append(result, linesOld[i]) 74 + i++ 75 + continue 76 + } 77 + 78 + oldLine := linesOld[i] 79 + newLine := linesNew[j] 80 + 81 + if oldLine != newLine && (oldLine != "" && newLine != "") { 82 + // context mismatch 83 + return "", fmt.Errorf("failed to merge files, found context mismatch at %d; oldLine: `%s`, newline: `%s`", i+1, oldLine, newLine) 84 + } 85 + 86 + if oldLine == newLine { 87 + result = append(result, oldLine) 88 + } else if oldLine == "" { 89 + result = append(result, newLine) 90 + } else if newLine == "" { 91 + result = append(result, oldLine) 92 + } 93 + i++ 94 + j++ 95 + } 96 + 97 + return strings.Join(result, "\n"), nil 98 + } 99 + 100 + func combineTwo(patch1, patch2 []*gitdiff.File) []*gitdiff.File { 101 + fileToIdx1 := make(map[string]int) 102 + fileToIdx2 := make(map[string]int) 103 + visited := make(map[string]struct{}) 104 + var result []*gitdiff.File 105 + 106 + for idx, f := range patch1 { 107 + fileToIdx1[bestName(f)] = idx 108 + } 109 + 110 + for idx, f := range patch2 { 111 + fileToIdx2[bestName(f)] = idx 112 + } 113 + 114 + for _, f1 := range patch1 { 115 + fileName := bestName(f1) 116 + if idx, ok := fileToIdx2[fileName]; ok { 117 + f2 := patch2[idx] 118 + 119 + // we have f1 and f2, combine them 120 + combined, err := combineFiles(f1, f2) 121 + if err != nil { 122 + fmt.Println(err) 123 + } 124 + 125 + result = append(result, combined) 126 + } else { 127 + // only in patch1; add as-is 128 + result = append(result, f1) 129 + } 130 + 131 + visited[fileName] = struct{}{} 132 + } 133 + 134 + // for all files in patch2 that remain unvisited; we can just add them into the output 135 + for _, f2 := range patch2 { 136 + fileName := bestName(f2) 137 + if _, ok := visited[fileName]; ok { 138 + continue 139 + } 140 + 141 + result = append(result, f2) 142 + } 143 + 144 + return result 145 + } 146 + 147 + // pairwise combination from first to last patch 148 + func CombineDiff(patches ...[]*gitdiff.File) []*gitdiff.File { 149 + if len(patches) == 0 { 150 + return nil 151 + } 152 + 153 + if len(patches) == 1 { 154 + return patches[0] 155 + } 156 + 157 + combined := combineTwo(patches[0], patches[1]) 158 + 159 + newPatches := [][]*gitdiff.File{} 160 + newPatches = append(newPatches, combined) 161 + for i, p := range patches { 162 + if i >= 2 { 163 + newPatches = append(newPatches, p) 164 + } 165 + } 166 + 167 + return CombineDiff(newPatches...) 168 + }
+178
patchutil/image.go
··· 1 + package patchutil 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/bluekeyes/go-gitdiff/gitdiff" 9 + ) 10 + 11 + type Line struct { 12 + LineNumber int64 13 + Content string 14 + IsUnknown bool 15 + } 16 + 17 + func NewLineAt(lineNumber int64, content string) Line { 18 + return Line{ 19 + LineNumber: lineNumber, 20 + Content: content, 21 + IsUnknown: false, 22 + } 23 + } 24 + 25 + type Image struct { 26 + File string 27 + Data []*Line 28 + } 29 + 30 + func (r *Image) String() string { 31 + var i, j int64 32 + var b strings.Builder 33 + for { 34 + i += 1 35 + 36 + if int(j) >= (len(r.Data)) { 37 + break 38 + } 39 + 40 + if r.Data[j].LineNumber == i { 41 + // b.WriteString(fmt.Sprintf("%d:", r.Data[j].LineNumber)) 42 + b.WriteString(r.Data[j].Content) 43 + j += 1 44 + } else { 45 + //b.WriteString(fmt.Sprintf("%d:\n", i)) 46 + b.WriteString("\n") 47 + } 48 + } 49 + 50 + return b.String() 51 + } 52 + 53 + func (r *Image) AddLine(line *Line) { 54 + r.Data = append(r.Data, line) 55 + } 56 + 57 + // rebuild the original file from a patch 58 + func CreatePreImage(file *gitdiff.File) Image { 59 + rf := Image{ 60 + File: bestName(file), 61 + } 62 + 63 + for _, fragment := range file.TextFragments { 64 + position := fragment.OldPosition 65 + for _, line := range fragment.Lines { 66 + switch line.Op { 67 + case gitdiff.OpContext: 68 + rl := NewLineAt(position, line.Line) 69 + rf.Data = append(rf.Data, &rl) 70 + position += 1 71 + case gitdiff.OpDelete: 72 + rl := NewLineAt(position, line.Line) 73 + rf.Data = append(rf.Data, &rl) 74 + position += 1 75 + case gitdiff.OpAdd: 76 + // do nothing here 77 + } 78 + } 79 + } 80 + 81 + return rf 82 + } 83 + 84 + // rebuild the revised file from a patch 85 + func CreatePostImage(file *gitdiff.File) Image { 86 + rf := Image{ 87 + File: bestName(file), 88 + } 89 + 90 + for _, fragment := range file.TextFragments { 91 + position := fragment.NewPosition 92 + for _, line := range fragment.Lines { 93 + switch line.Op { 94 + case gitdiff.OpContext: 95 + rl := NewLineAt(position, line.Line) 96 + rf.Data = append(rf.Data, &rl) 97 + position += 1 98 + case gitdiff.OpAdd: 99 + rl := NewLineAt(position, line.Line) 100 + rf.Data = append(rf.Data, &rl) 101 + position += 1 102 + case gitdiff.OpDelete: 103 + // do nothing here 104 + } 105 + } 106 + } 107 + 108 + return rf 109 + } 110 + 111 + type MergeError struct { 112 + msg string 113 + mismatchingLine int64 114 + } 115 + 116 + func (m MergeError) Error() string { 117 + return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLine) 118 + } 119 + 120 + // best effort merging of two reconstructed files 121 + func (this *Image) Merge(other *Image) (*Image, error) { 122 + mergedFile := Image{} 123 + 124 + var i, j int64 125 + 126 + for int(i) < len(this.Data) || int(j) < len(other.Data) { 127 + if int(i) >= len(this.Data) { 128 + // first file is done; the rest of the lines from file 2 can go in 129 + mergedFile.AddLine(other.Data[j]) 130 + j++ 131 + continue 132 + } 133 + 134 + if int(j) >= len(other.Data) { 135 + // first file is done; the rest of the lines from file 2 can go in 136 + mergedFile.AddLine(this.Data[i]) 137 + i++ 138 + continue 139 + } 140 + 141 + line1 := this.Data[i] 142 + line2 := other.Data[j] 143 + 144 + if line1.LineNumber == line2.LineNumber { 145 + if line1.Content != line2.Content { 146 + return nil, MergeError{ 147 + msg: "mismatching lines, this patch might have undergone rebase", 148 + mismatchingLine: line1.LineNumber, 149 + } 150 + } else { 151 + mergedFile.AddLine(line1) 152 + } 153 + i++ 154 + j++ 155 + } else if line1.LineNumber < line2.LineNumber { 156 + mergedFile.AddLine(line1) 157 + i++ 158 + } else { 159 + mergedFile.AddLine(line2) 160 + j++ 161 + } 162 + } 163 + 164 + return &mergedFile, nil 165 + } 166 + 167 + func (r *Image) Apply(patch *gitdiff.File) (string, error) { 168 + original := r.String() 169 + var buffer bytes.Buffer 170 + reader := strings.NewReader(original) 171 + 172 + err := gitdiff.Apply(&buffer, reader, patch) 173 + if err != nil { 174 + return "", err 175 + } 176 + 177 + return buffer.String(), nil 178 + }
+244
patchutil/interdiff.go
··· 1 + package patchutil 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + ) 9 + 10 + type InterdiffResult struct { 11 + Files []*InterdiffFile 12 + } 13 + 14 + func (i *InterdiffResult) AffectedFiles() []string { 15 + files := make([]string, len(i.Files)) 16 + for _, f := range i.Files { 17 + files = append(files, f.Name) 18 + } 19 + return files 20 + } 21 + 22 + func (i *InterdiffResult) String() string { 23 + var b strings.Builder 24 + for _, f := range i.Files { 25 + b.WriteString(f.String()) 26 + b.WriteString("\n") 27 + } 28 + 29 + return b.String() 30 + } 31 + 32 + type InterdiffFile struct { 33 + *gitdiff.File 34 + Name string 35 + Status InterdiffFileStatus 36 + } 37 + 38 + func (s *InterdiffFile) String() string { 39 + var b strings.Builder 40 + b.WriteString(s.Status.String()) 41 + b.WriteString(" ") 42 + 43 + if s.File != nil { 44 + b.WriteString(bestName(s.File)) 45 + b.WriteString("\n") 46 + b.WriteString(s.File.String()) 47 + } 48 + 49 + return b.String() 50 + } 51 + 52 + type InterdiffFileStatus struct { 53 + StatusKind StatusKind 54 + Error error 55 + } 56 + 57 + func (s *InterdiffFileStatus) String() string { 58 + kind := s.StatusKind.String() 59 + if s.Error != nil { 60 + return fmt.Sprintf("%s [%s]", kind, s.Error.Error()) 61 + } else { 62 + return kind 63 + } 64 + } 65 + 66 + func (s *InterdiffFileStatus) IsOk() bool { 67 + return s.StatusKind == StatusOk 68 + } 69 + 70 + func (s *InterdiffFileStatus) IsUnchanged() bool { 71 + return s.StatusKind == StatusUnchanged 72 + } 73 + 74 + func (s *InterdiffFileStatus) IsOnlyInOne() bool { 75 + return s.StatusKind == StatusOnlyInOne 76 + } 77 + 78 + func (s *InterdiffFileStatus) IsOnlyInTwo() bool { 79 + return s.StatusKind == StatusOnlyInTwo 80 + } 81 + 82 + func (s *InterdiffFileStatus) IsRebased() bool { 83 + return s.StatusKind == StatusRebased 84 + } 85 + 86 + func (s *InterdiffFileStatus) IsError() bool { 87 + return s.StatusKind == StatusError 88 + } 89 + 90 + type StatusKind int 91 + 92 + func (k StatusKind) String() string { 93 + switch k { 94 + case StatusOnlyInOne: 95 + return "only in one" 96 + case StatusOnlyInTwo: 97 + return "only in two" 98 + case StatusUnchanged: 99 + return "unchanged" 100 + case StatusRebased: 101 + return "rebased" 102 + case StatusError: 103 + return "error" 104 + default: 105 + return "changed" 106 + } 107 + } 108 + 109 + const ( 110 + StatusOk StatusKind = iota 111 + StatusOnlyInOne 112 + StatusOnlyInTwo 113 + StatusUnchanged 114 + StatusRebased 115 + StatusError 116 + ) 117 + 118 + func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile { 119 + re1 := CreatePreImage(f1) 120 + re2 := CreatePreImage(f2) 121 + 122 + interdiffFile := InterdiffFile{ 123 + Name: bestName(f1), 124 + } 125 + 126 + merged, err := re1.Merge(&re2) 127 + if err != nil { 128 + interdiffFile.Status = InterdiffFileStatus{ 129 + StatusKind: StatusRebased, 130 + Error: err, 131 + } 132 + return &interdiffFile 133 + } 134 + 135 + rev1, err := merged.Apply(f1) 136 + if err != nil { 137 + interdiffFile.Status = InterdiffFileStatus{ 138 + StatusKind: StatusError, 139 + Error: err, 140 + } 141 + return &interdiffFile 142 + } 143 + 144 + rev2, err := merged.Apply(f2) 145 + if err != nil { 146 + interdiffFile.Status = InterdiffFileStatus{ 147 + StatusKind: StatusError, 148 + Error: err, 149 + } 150 + return &interdiffFile 151 + } 152 + 153 + diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2)) 154 + if err != nil { 155 + interdiffFile.Status = InterdiffFileStatus{ 156 + StatusKind: StatusError, 157 + Error: err, 158 + } 159 + return &interdiffFile 160 + } 161 + 162 + parsed, _, err := gitdiff.Parse(strings.NewReader(diff)) 163 + if err != nil { 164 + interdiffFile.Status = InterdiffFileStatus{ 165 + StatusKind: StatusError, 166 + Error: err, 167 + } 168 + return &interdiffFile 169 + } 170 + 171 + if len(parsed) != 1 { 172 + // files are identical? 173 + interdiffFile.Status = InterdiffFileStatus{ 174 + StatusKind: StatusUnchanged, 175 + } 176 + return &interdiffFile 177 + } 178 + 179 + if interdiffFile.Status.StatusKind == StatusOk { 180 + interdiffFile.File = parsed[0] 181 + } 182 + 183 + return &interdiffFile 184 + } 185 + 186 + func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult { 187 + fileToIdx1 := make(map[string]int) 188 + fileToIdx2 := make(map[string]int) 189 + visited := make(map[string]struct{}) 190 + var result InterdiffResult 191 + 192 + for idx, f := range patch1 { 193 + fileToIdx1[bestName(f)] = idx 194 + } 195 + 196 + for idx, f := range patch2 { 197 + fileToIdx2[bestName(f)] = idx 198 + } 199 + 200 + for _, f1 := range patch1 { 201 + var interdiffFile *InterdiffFile 202 + 203 + fileName := bestName(f1) 204 + if idx, ok := fileToIdx2[fileName]; ok { 205 + f2 := patch2[idx] 206 + 207 + // we have f1 and f2, calculate interdiff 208 + interdiffFile = interdiffFiles(f1, f2) 209 + } else { 210 + // only in patch 1, this change would have to be "inverted" to dissapear 211 + // from patch 2, so we reverseDiff(f1) 212 + reverseDiff(f1) 213 + 214 + interdiffFile = &InterdiffFile{ 215 + File: f1, 216 + Name: fileName, 217 + Status: InterdiffFileStatus{ 218 + StatusKind: StatusOnlyInOne, 219 + }, 220 + } 221 + } 222 + 223 + result.Files = append(result.Files, interdiffFile) 224 + visited[fileName] = struct{}{} 225 + } 226 + 227 + // for all files in patch2 that remain unvisited; we can just add them into the output 228 + for _, f2 := range patch2 { 229 + fileName := bestName(f2) 230 + if _, ok := visited[fileName]; ok { 231 + continue 232 + } 233 + 234 + result.Files = append(result.Files, &InterdiffFile{ 235 + File: f2, 236 + Name: fileName, 237 + Status: InterdiffFileStatus{ 238 + StatusKind: StatusOnlyInTwo, 239 + }, 240 + }) 241 + } 242 + 243 + return &result 244 + }
+196
patchutil/patchutil.go
··· 1 + package patchutil 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "os/exec" 7 + "regexp" 8 + "strings" 9 + 10 + "github.com/bluekeyes/go-gitdiff/gitdiff" 11 + ) 12 + 13 + type FormatPatch struct { 14 + Files []*gitdiff.File 15 + *gitdiff.PatchHeader 16 + } 17 + 18 + func ExtractPatches(formatPatch string) ([]FormatPatch, error) { 19 + patches := splitFormatPatch(formatPatch) 20 + 21 + result := []FormatPatch{} 22 + 23 + for _, patch := range patches { 24 + files, headerStr, err := gitdiff.Parse(strings.NewReader(patch)) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse patch: %w", err) 27 + } 28 + 29 + header, err := gitdiff.ParsePatchHeader(headerStr) 30 + if err != nil { 31 + return nil, fmt.Errorf("failed to parse patch header: %w", err) 32 + } 33 + 34 + result = append(result, FormatPatch{ 35 + Files: files, 36 + PatchHeader: header, 37 + }) 38 + } 39 + 40 + return result, nil 41 + } 42 + 43 + // IsPatchValid checks if the given patch string is valid. 44 + // It performs very basic sniffing for either git-diff or git-format-patch 45 + // header lines. For format patches, it attempts to extract and validate each one. 46 + func IsPatchValid(patch string) bool { 47 + if len(patch) == 0 { 48 + return false 49 + } 50 + 51 + lines := strings.Split(patch, "\n") 52 + if len(lines) < 2 { 53 + return false 54 + } 55 + 56 + firstLine := strings.TrimSpace(lines[0]) 57 + 58 + // check if it's a git diff 59 + if strings.HasPrefix(firstLine, "diff ") || 60 + strings.HasPrefix(firstLine, "--- ") || 61 + strings.HasPrefix(firstLine, "Index: ") || 62 + strings.HasPrefix(firstLine, "+++ ") || 63 + strings.HasPrefix(firstLine, "@@ ") { 64 + return true 65 + } 66 + 67 + // check if it's format-patch 68 + if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") || 69 + strings.HasPrefix(firstLine, "From: ") { 70 + // ExtractPatches already runs it through gitdiff.Parse so if that errors, 71 + // it's safe to say it's broken. 72 + patches, err := ExtractPatches(patch) 73 + if err != nil { 74 + return false 75 + } 76 + return len(patches) > 0 77 + } 78 + 79 + return false 80 + } 81 + 82 + func IsFormatPatch(patch string) bool { 83 + lines := strings.Split(patch, "\n") 84 + if len(lines) < 2 { 85 + return false 86 + } 87 + 88 + firstLine := strings.TrimSpace(lines[0]) 89 + if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") { 90 + return true 91 + } 92 + 93 + headerCount := 0 94 + for i := range min(10, len(lines)) { 95 + line := strings.TrimSpace(lines[i]) 96 + if strings.HasPrefix(line, "From: ") || 97 + strings.HasPrefix(line, "Date: ") || 98 + strings.HasPrefix(line, "Subject: ") || 99 + strings.HasPrefix(line, "commit ") { 100 + headerCount++ 101 + } 102 + } 103 + 104 + return headerCount >= 2 105 + } 106 + 107 + func splitFormatPatch(patchText string) []string { 108 + re := regexp.MustCompile(`(?m)^From [0-9a-f]{40} .*$`) 109 + 110 + indexes := re.FindAllStringIndex(patchText, -1) 111 + 112 + if len(indexes) == 0 { 113 + return []string{} 114 + } 115 + 116 + patches := make([]string, len(indexes)) 117 + 118 + for i := range indexes { 119 + startPos := indexes[i][0] 120 + endPos := len(patchText) 121 + 122 + if i < len(indexes)-1 { 123 + endPos = indexes[i+1][0] 124 + } 125 + 126 + patches[i] = strings.TrimSpace(patchText[startPos:endPos]) 127 + } 128 + return patches 129 + } 130 + 131 + func bestName(file *gitdiff.File) string { 132 + if file.IsDelete { 133 + return file.OldName 134 + } else { 135 + return file.NewName 136 + } 137 + } 138 + 139 + // in-place reverse of a diff 140 + func reverseDiff(file *gitdiff.File) { 141 + file.OldName, file.NewName = file.NewName, file.OldName 142 + file.OldMode, file.NewMode = file.NewMode, file.OldMode 143 + file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment 144 + 145 + for _, fragment := range file.TextFragments { 146 + // swap postions 147 + fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition 148 + fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines 149 + fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded 150 + 151 + for i := range fragment.Lines { 152 + switch fragment.Lines[i].Op { 153 + case gitdiff.OpAdd: 154 + fragment.Lines[i].Op = gitdiff.OpDelete 155 + case gitdiff.OpDelete: 156 + fragment.Lines[i].Op = gitdiff.OpAdd 157 + default: 158 + // do nothing 159 + } 160 + } 161 + } 162 + } 163 + 164 + func Unified(oldText, oldFile, newText, newFile string) (string, error) { 165 + oldTemp, err := os.CreateTemp("", "old_*") 166 + if err != nil { 167 + return "", fmt.Errorf("failed to create temp file for oldText: %w", err) 168 + } 169 + defer os.Remove(oldTemp.Name()) 170 + if _, err := oldTemp.WriteString(oldText); err != nil { 171 + return "", fmt.Errorf("failed to write to old temp file: %w", err) 172 + } 173 + oldTemp.Close() 174 + 175 + newTemp, err := os.CreateTemp("", "new_*") 176 + if err != nil { 177 + return "", fmt.Errorf("failed to create temp file for newText: %w", err) 178 + } 179 + defer os.Remove(newTemp.Name()) 180 + if _, err := newTemp.WriteString(newText); err != nil { 181 + return "", fmt.Errorf("failed to write to new temp file: %w", err) 182 + } 183 + newTemp.Close() 184 + 185 + cmd := exec.Command("diff", "-u", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name()) 186 + output, err := cmd.CombinedOutput() 187 + 188 + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { 189 + return string(output), nil 190 + } 191 + if err != nil { 192 + return "", fmt.Errorf("diff command failed: %w", err) 193 + } 194 + 195 + return string(output), nil 196 + }
+324
patchutil/patchutil_test.go
··· 1 + package patchutil 2 + 3 + import ( 4 + "reflect" 5 + "testing" 6 + ) 7 + 8 + func TestIsPatchValid(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + patch string 12 + expected bool 13 + }{ 14 + { 15 + name: `empty patch`, 16 + patch: ``, 17 + expected: false, 18 + }, 19 + { 20 + name: `single line patch`, 21 + patch: `single line`, 22 + expected: false, 23 + }, 24 + { 25 + name: `valid diff patch`, 26 + patch: `diff --git a/file.txt b/file.txt 27 + index abc..def 100644 28 + --- a/file.txt 29 + +++ b/file.txt 30 + @@ -1,3 +1,3 @@ 31 + -old line 32 + +new line 33 + context`, 34 + expected: true, 35 + }, 36 + { 37 + name: `valid patch starting with ---`, 38 + patch: `--- a/file.txt 39 + +++ b/file.txt 40 + @@ -1,3 +1,3 @@ 41 + -old line 42 + +new line 43 + context`, 44 + expected: true, 45 + }, 46 + { 47 + name: `valid patch starting with Index`, 48 + patch: `Index: file.txt 49 + ========== 50 + --- a/file.txt 51 + +++ b/file.txt 52 + @@ -1,3 +1,3 @@ 53 + -old line 54 + +new line 55 + context`, 56 + expected: true, 57 + }, 58 + { 59 + name: `valid patch starting with +++`, 60 + patch: `+++ b/file.txt 61 + --- a/file.txt 62 + @@ -1,3 +1,3 @@ 63 + -old line 64 + +new line 65 + context`, 66 + expected: true, 67 + }, 68 + { 69 + name: `valid patch starting with @@`, 70 + patch: `@@ -1,3 +1,3 @@ 71 + -old line 72 + +new line 73 + context 74 + `, 75 + expected: true, 76 + }, 77 + { 78 + name: `valid format patch`, 79 + patch: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 80 + From: Author <author@example.com> 81 + Date: Wed, 16 Apr 2025 11:01:00 +0300 82 + Subject: [PATCH] Example patch 83 + 84 + diff --git a/file.txt b/file.txt 85 + index 123456..789012 100644 86 + --- a/file.txt 87 + +++ b/file.txt 88 + @@ -1 +1 @@ 89 + -old content 90 + +new content 91 + -- 92 + 2.48.1`, 93 + expected: true, 94 + }, 95 + { 96 + name: `invalid format patch`, 97 + patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 98 + From: Author <author@example.com> 99 + This is not a valid patch format`, 100 + expected: false, 101 + }, 102 + { 103 + name: `not a patch at all`, 104 + patch: `This is 105 + just some 106 + random text 107 + that isn't a patch`, 108 + expected: false, 109 + }, 110 + } 111 + 112 + for _, tt := range tests { 113 + t.Run(tt.name, func(t *testing.T) { 114 + result := IsPatchValid(tt.patch) 115 + if result != tt.expected { 116 + t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 117 + } 118 + }) 119 + } 120 + } 121 + 122 + func TestSplitPatches(t *testing.T) { 123 + tests := []struct { 124 + name string 125 + input string 126 + expected []string 127 + }{ 128 + { 129 + name: "Empty input", 130 + input: "", 131 + expected: []string{}, 132 + }, 133 + { 134 + name: "No valid patches", 135 + input: "This is not a \nJust some random text", 136 + expected: []string{}, 137 + }, 138 + { 139 + name: "Single patch", 140 + input: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 141 + From: Author <author@example.com> 142 + Date: Wed, 16 Apr 2025 11:01:00 +0300 143 + Subject: [PATCH] Example patch 144 + 145 + diff --git a/file.txt b/file.txt 146 + index 123456..789012 100644 147 + --- a/file.txt 148 + +++ b/file.txt 149 + @@ -1 +1 @@ 150 + -old content 151 + +new content 152 + -- 153 + 2.48.1`, 154 + expected: []string{ 155 + `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 156 + From: Author <author@example.com> 157 + Date: Wed, 16 Apr 2025 11:01:00 +0300 158 + Subject: [PATCH] Example patch 159 + 160 + diff --git a/file.txt b/file.txt 161 + index 123456..789012 100644 162 + --- a/file.txt 163 + +++ b/file.txt 164 + @@ -1 +1 @@ 165 + -old content 166 + +new content 167 + -- 168 + 2.48.1`, 169 + }, 170 + }, 171 + { 172 + name: "Two patches", 173 + input: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 174 + From: Author <author@example.com> 175 + Date: Wed, 16 Apr 2025 11:01:00 +0300 176 + Subject: [PATCH 1/2] First patch 177 + 178 + diff --git a/file1.txt b/file1.txt 179 + index 123456..789012 100644 180 + --- a/file1.txt 181 + +++ b/file1.txt 182 + @@ -1 +1 @@ 183 + -old content 184 + +new content 185 + -- 186 + 2.48.1 187 + From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 188 + From: Author <author@example.com> 189 + Date: Wed, 16 Apr 2025 11:03:11 +0300 190 + Subject: [PATCH 2/2] Second patch 191 + 192 + diff --git a/file2.txt b/file2.txt 193 + index abcdef..ghijkl 100644 194 + --- a/file2.txt 195 + +++ b/file2.txt 196 + @@ -1 +1 @@ 197 + -foo bar 198 + +baz qux 199 + -- 200 + 2.48.1`, 201 + expected: []string{ 202 + `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 203 + From: Author <author@example.com> 204 + Date: Wed, 16 Apr 2025 11:01:00 +0300 205 + Subject: [PATCH 1/2] First patch 206 + 207 + diff --git a/file1.txt b/file1.txt 208 + index 123456..789012 100644 209 + --- a/file1.txt 210 + +++ b/file1.txt 211 + @@ -1 +1 @@ 212 + -old content 213 + +new content 214 + -- 215 + 2.48.1`, 216 + `From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 217 + From: Author <author@example.com> 218 + Date: Wed, 16 Apr 2025 11:03:11 +0300 219 + Subject: [PATCH 2/2] Second patch 220 + 221 + diff --git a/file2.txt b/file2.txt 222 + index abcdef..ghijkl 100644 223 + --- a/file2.txt 224 + +++ b/file2.txt 225 + @@ -1 +1 @@ 226 + -foo bar 227 + +baz qux 228 + -- 229 + 2.48.1`, 230 + }, 231 + }, 232 + { 233 + name: "Patches with additional text between them", 234 + input: `Some text before the patches 235 + 236 + From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 237 + From: Author <author@example.com> 238 + Subject: [PATCH] First patch 239 + 240 + diff content here 241 + -- 242 + 2.48.1 243 + 244 + Some text between patches 245 + 246 + From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 247 + From: Author <author@example.com> 248 + Subject: [PATCH] Second patch 249 + 250 + more diff content 251 + -- 252 + 2.48.1 253 + 254 + Text after patches`, 255 + expected: []string{ 256 + `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 257 + From: Author <author@example.com> 258 + Subject: [PATCH] First patch 259 + 260 + diff content here 261 + -- 262 + 2.48.1 263 + 264 + Some text between patches`, 265 + `From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 266 + From: Author <author@example.com> 267 + Subject: [PATCH] Second patch 268 + 269 + more diff content 270 + -- 271 + 2.48.1 272 + 273 + Text after patches`, 274 + }, 275 + }, 276 + { 277 + name: "Patches with whitespace padding", 278 + input: ` 279 + 280 + From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 281 + From: Author <author@example.com> 282 + Subject: Patch 283 + 284 + content 285 + -- 286 + 2.48.1 287 + 288 + 289 + From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 290 + From: Author <author@example.com> 291 + Subject: Another patch 292 + 293 + content 294 + -- 295 + 2.48.1 296 + `, 297 + expected: []string{ 298 + `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 299 + From: Author <author@example.com> 300 + Subject: Patch 301 + 302 + content 303 + -- 304 + 2.48.1`, 305 + `From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 306 + From: Author <author@example.com> 307 + Subject: Another patch 308 + 309 + content 310 + -- 311 + 2.48.1`, 312 + }, 313 + }, 314 + } 315 + 316 + for _, tt := range tests { 317 + t.Run(tt.name, func(t *testing.T) { 318 + result := splitFormatPatch(tt.input) 319 + if !reflect.DeepEqual(result, tt.expected) { 320 + t.Errorf("splitPatches() = %v, want %v", result, tt.expected) 321 + } 322 + }) 323 + } 324 + }
+1 -17
rbac/rbac.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "fmt" 6 - "path" 7 6 "strings" 8 7 9 8 adapter "github.com/Blank-Xu/sql-adapter" ··· 26 25 e = some(where (p.eft == allow)) 27 26 28 27 [matchers] 29 - m = r.act == p.act && r.dom == p.dom && keyMatch2(r.obj, p.obj) && g(r.sub, p.sub, r.dom) 28 + m = r.act == p.act && r.dom == p.dom && r.obj == p.obj && g(r.sub, p.sub, r.dom) 30 29 ` 31 30 ) 32 31 ··· 34 33 E *casbin.Enforcer 35 34 } 36 35 37 - func keyMatch2(key1 string, key2 string) bool { 38 - matched, _ := path.Match(key2, key1) 39 - return matched 40 - } 41 - 42 36 func NewEnforcer(path string) (*Enforcer, error) { 43 37 m, err := model.NewModelFromString(Model) 44 38 if err != nil { ··· 61 55 } 62 56 63 57 e.EnableAutoSave(false) 64 - 65 - e.AddFunction("keyMatch2", keyMatch2Func) 66 58 67 59 return &Enforcer{e}, nil 68 60 } ··· 209 201 } 210 202 211 203 return permissions 212 - } 213 - 214 - // keyMatch2Func is a wrapper for keyMatch2 to make it compatible with Casbin 215 - func keyMatch2Func(args ...interface{}) (interface{}, error) { 216 - name1 := args[0].(string) 217 - name2 := args[1].(string) 218 - 219 - return keyMatch2(name1, name2), nil 220 204 } 221 205 222 206 func checkRepoFormat(repo string) error {
+9 -3
readme.md
··· 6 6 7 7 Read the introduction to Tangled [here](https://blog.tangled.sh/intro). 8 8 9 - Documentation: 9 + ## docs 10 + 10 11 * [knot hosting 11 - guide](https://tangled.sh/@tangled.sh/core/tree/master/docs/knot-hosting.md) 12 + guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md) 12 13 * [contributing 13 - guide](https://tangled.sh/@tangled.sh/core/tree/master/docs/contributing.md)&mdash;**read this before opening a PR!** 14 + guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/contributing.md)&mdash;**read this before opening a PR!** 15 + 16 + ## security 17 + 18 + If you've identified a security issue in Tangled, please email 19 + [security@tangled.sh](mailto:security@tangled.sh) with details!
+68 -45
tailwind.config.js
··· 2 2 const colors = require("tailwindcss/colors"); 3 3 4 4 module.exports = { 5 - content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"], 6 - darkMode: "media", 7 - theme: { 8 - container: { 9 - padding: "2rem", 10 - center: true, 11 - screens: { 12 - sm: "500px", 13 - md: "600px", 14 - lg: "800px", 15 - xl: "1000px", 16 - "2xl": "1200px", 17 - }, 18 - }, 19 - extend: { 20 - fontFamily: { 21 - sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"], 22 - mono: [ 23 - "IBMPlexMono", 24 - "ui-monospace", 25 - "SFMono-Regular", 26 - "Menlo", 27 - "Monaco", 28 - "Consolas", 29 - "Liberation Mono", 30 - "Courier New", 31 - "monospace", 32 - ], 33 - }, 34 - typography: { 35 - DEFAULT: { 36 - css: { 37 - maxWidth: "none", 38 - pre: { 39 - backgroundColor: colors.gray[100], 40 - color: colors.black, 41 - "@apply dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": 42 - {}, 43 - }, 44 - }, 45 - }, 46 - }, 47 - }, 48 - }, 49 - plugins: [require("@tailwindcss/typography")], 5 + content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"], 6 + darkMode: "media", 7 + theme: { 8 + container: { 9 + padding: "2rem", 10 + center: true, 11 + screens: { 12 + sm: "500px", 13 + md: "600px", 14 + lg: "800px", 15 + xl: "1000px", 16 + "2xl": "1200px", 17 + }, 18 + }, 19 + extend: { 20 + fontFamily: { 21 + sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"], 22 + mono: [ 23 + "IBMPlexMono", 24 + "ui-monospace", 25 + "SFMono-Regular", 26 + "Menlo", 27 + "Monaco", 28 + "Consolas", 29 + "Liberation Mono", 30 + "Courier New", 31 + "monospace", 32 + ], 33 + }, 34 + typography: { 35 + DEFAULT: { 36 + css: { 37 + maxWidth: "none", 38 + pre: { 39 + backgroundColor: colors.gray[100], 40 + color: colors.black, 41 + "@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {}, 42 + }, 43 + code: { 44 + "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {}, 45 + }, 46 + "code::before": { 47 + content: '""', 48 + }, 49 + "code::after": { 50 + content: '""', 51 + }, 52 + blockquote: { 53 + quotes: "none", 54 + }, 55 + 'h1, h2, h3, h4': { 56 + "@apply mt-4 mb-2": {} 57 + }, 58 + h1: { 59 + "@apply mt-3 pb-3 border-b border-gray-300 dark:border-gray-600": {} 60 + }, 61 + h2: { 62 + "@apply mt-3 pb-3 border-b border-gray-200 dark:border-gray-700": {} 63 + }, 64 + h3: { 65 + "@apply mt-2": {} 66 + }, 67 + }, 68 + }, 69 + }, 70 + }, 71 + }, 72 + plugins: [require("@tailwindcss/typography")], 50 73 };
+1
types/capabilities.go
··· 2 2 3 3 type Capabilities struct { 4 4 PullRequests struct { 5 + FormatPatch bool `json:"format_patch"` 5 6 PatchSubmissions bool `json:"patch_submissions"` 6 7 BranchSubmissions bool `json:"branch_submissions"` 7 8 ForkSubmissions bool `json:"fork_submissions"`
+28
types/diff.go
··· 23 23 IsRename bool `json:"is_rename"` 24 24 } 25 25 26 + type DiffStat struct { 27 + Insertions int64 28 + Deletions int64 29 + } 30 + 31 + func (d *Diff) Stats() DiffStat { 32 + var stats DiffStat 33 + for _, f := range d.TextFragments { 34 + stats.Insertions += f.LinesAdded 35 + stats.Deletions += f.LinesDeleted 36 + } 37 + return stats 38 + } 39 + 26 40 // A nicer git diff representation. 27 41 type NiceDiff struct { 28 42 Commit struct { ··· 45 59 Patch string `json:"patch"` 46 60 Diff []*gitdiff.File `json:"diff"` 47 61 } 62 + 63 + func (d *NiceDiff) ChangedFiles() []string { 64 + files := make([]string, len(d.Diff)) 65 + 66 + for i, f := range d.Diff { 67 + if f.IsDelete { 68 + files[i] = f.Name.Old 69 + } else { 70 + files[i] = f.Name.New 71 + } 72 + } 73 + 74 + return files 75 + }
+6 -2
types/repo.go
··· 2 2 3 3 import ( 4 4 "github.com/go-git/go-git/v5/plumbing/object" 5 + "tangled.sh/tangled.sh/core/patchutil" 5 6 ) 6 7 7 8 type RepoIndexResponse struct { ··· 32 33 Diff *NiceDiff `json:"diff,omitempty"` 33 34 } 34 35 35 - type RepoDiffTreeResponse struct { 36 - DiffTree *DiffTree `json:"difftree,omitempty"` 36 + type RepoFormatPatchResponse struct { 37 + Rev1 string `json:"rev1,omitempty"` 38 + Rev2 string `json:"rev2,omitempty"` 39 + FormatPatch []patchutil.FormatPatch `json:"format_patch,omitempty"` 40 + Patch string `json:"patch,omitempty"` 37 41 } 38 42 39 43 type RepoTreeResponse struct {