Monorepo for Tangled tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+6264 -3339
.tangled
workflows
api
appview
db
issues
knots
labels
middleware
models
notifications
oauth
ogcard
pages
pipelines
pulls
repo
reporesolver
settings
spindles
state
strings
cmd
dolly
docs
ico
knotserver
git
lexicons
nix
patchutil
spindle
types
+1 -20
.tangled/workflows/test.yml
··· 1 when: 2 - event: ["push", "pull_request"] 3 - branch: ["master","ci"] 4 5 engine: nixery 6 ··· 13 - name: patch static dir 14 command: | 15 mkdir -p appview/pages/static; touch appview/pages/static/x 16 - 17 - - name: run go mod tidy 18 - command: go mod tidy 19 - 20 - - name: run gomod2nix 21 - command: | 22 - # this var is overridden by the env setup otherwise 23 - export HOME=/tmp/build-home 24 - mkdir -p $HOME 25 - rm -rf /homeless-shelter || true 26 - nix run .#gomod2nix 27 - 28 - - name: verify no changes 29 - command: | 30 - if ! git diff --quiet; then 31 - echo "Error: gomod2nix produced changes. Please commit the updated files." 32 - git diff 33 - exit 1 34 - fi 35 36 - name: run linter 37 environment:
··· 1 when: 2 - event: ["push", "pull_request"] 3 + branch: master 4 5 engine: nixery 6 ··· 13 - name: patch static dir 14 command: | 15 mkdir -p appview/pages/static; touch appview/pages/static/x 16 17 - name: run linter 18 environment:
+79 -20
api/tangled/cbor_gen.go
··· 7934 } 7935 7936 cw := cbg.NewCborWriter(w) 7937 - fieldCount := 9 7938 7939 if t.Body == nil { 7940 fieldCount-- 7941 } 7942 7943 if t.Mentions == nil { 7944 fieldCount-- 7945 } 7946 ··· 8008 } 8009 8010 // t.Patch (string) (string) 8011 - if len("patch") > 1000000 { 8012 - return xerrors.Errorf("Value in field \"patch\" was too long") 8013 - } 8014 8015 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil { 8016 - return err 8017 - } 8018 - if _, err := cw.WriteString(string("patch")); err != nil { 8019 - return err 8020 - } 8021 8022 - if len(t.Patch) > 1000000 { 8023 - return xerrors.Errorf("Value in field t.Patch was too long") 8024 - } 8025 8026 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Patch))); err != nil { 8027 - return err 8028 - } 8029 - if _, err := cw.WriteString(string(t.Patch)); err != nil { 8030 - return err 8031 } 8032 8033 // t.Title (string) (string) ··· 8147 return err 8148 } 8149 8150 // t.References ([]string) (slice) 8151 if t.References != nil { 8152 ··· 8262 case "patch": 8263 8264 { 8265 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 8266 if err != nil { 8267 return err 8268 } 8269 8270 - t.Patch = string(sval) 8271 } 8272 // t.Title (string) (string) 8273 case "title": ··· 8370 } 8371 8372 t.CreatedAt = string(sval) 8373 } 8374 // t.References ([]string) (slice) 8375 case "references":
··· 7934 } 7935 7936 cw := cbg.NewCborWriter(w) 7937 + fieldCount := 10 7938 7939 if t.Body == nil { 7940 fieldCount-- 7941 } 7942 7943 if t.Mentions == nil { 7944 + fieldCount-- 7945 + } 7946 + 7947 + if t.Patch == nil { 7948 fieldCount-- 7949 } 7950 ··· 8012 } 8013 8014 // t.Patch (string) (string) 8015 + if t.Patch != nil { 8016 8017 + if len("patch") > 1000000 { 8018 + return xerrors.Errorf("Value in field \"patch\" was too long") 8019 + } 8020 8021 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil { 8022 + return err 8023 + } 8024 + if _, err := cw.WriteString(string("patch")); err != nil { 8025 + return err 8026 + } 8027 + 8028 + if t.Patch == nil { 8029 + if _, err := cw.Write(cbg.CborNull); err != nil { 8030 + return err 8031 + } 8032 + } else { 8033 + if len(*t.Patch) > 1000000 { 8034 + return xerrors.Errorf("Value in field t.Patch was too long") 8035 + } 8036 8037 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Patch))); err != nil { 8038 + return err 8039 + } 8040 + if _, err := cw.WriteString(string(*t.Patch)); err != nil { 8041 + return err 8042 + } 8043 + } 8044 } 8045 8046 // t.Title (string) (string) ··· 8160 return err 8161 } 8162 8163 + // t.PatchBlob (util.LexBlob) (struct) 8164 + if len("patchBlob") > 1000000 { 8165 + return xerrors.Errorf("Value in field \"patchBlob\" was too long") 8166 + } 8167 + 8168 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patchBlob"))); err != nil { 8169 + return err 8170 + } 8171 + if _, err := cw.WriteString(string("patchBlob")); err != nil { 8172 + return err 8173 + } 8174 + 8175 + if err := t.PatchBlob.MarshalCBOR(cw); err != nil { 8176 + return err 8177 + } 8178 + 8179 // t.References ([]string) (slice) 8180 if t.References != nil { 8181 ··· 8291 case "patch": 8292 8293 { 8294 + b, err := cr.ReadByte() 8295 if err != nil { 8296 return err 8297 } 8298 + if b != cbg.CborNull[0] { 8299 + if err := cr.UnreadByte(); err != nil { 8300 + return err 8301 + } 8302 8303 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8304 + if err != nil { 8305 + return err 8306 + } 8307 + 8308 + t.Patch = (*string)(&sval) 8309 + } 8310 } 8311 // t.Title (string) (string) 8312 case "title": ··· 8409 } 8410 8411 t.CreatedAt = string(sval) 8412 + } 8413 + // t.PatchBlob (util.LexBlob) (struct) 8414 + case "patchBlob": 8415 + 8416 + { 8417 + 8418 + b, err := cr.ReadByte() 8419 + if err != nil { 8420 + return err 8421 + } 8422 + if b != cbg.CborNull[0] { 8423 + if err := cr.UnreadByte(); err != nil { 8424 + return err 8425 + } 8426 + t.PatchBlob = new(util.LexBlob) 8427 + if err := t.PatchBlob.UnmarshalCBOR(cr); err != nil { 8428 + return xerrors.Errorf("unmarshaling t.PatchBlob pointer: %w", err) 8429 + } 8430 + } 8431 + 8432 } 8433 // t.References ([]string) (slice) 8434 case "references":
+34
api/tangled/pipelinecancelPipeline.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.pipeline.cancelPipeline 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + PipelineCancelPipelineNSID = "sh.tangled.pipeline.cancelPipeline" 15 + ) 16 + 17 + // PipelineCancelPipeline_Input is the input argument to a sh.tangled.pipeline.cancelPipeline call. 18 + type PipelineCancelPipeline_Input struct { 19 + // pipeline: pipeline at-uri 20 + Pipeline string `json:"pipeline" cborgen:"pipeline"` 21 + // repo: repo at-uri, spindle can't resolve repo from pipeline at-uri yet 22 + Repo string `json:"repo" cborgen:"repo"` 23 + // workflow: workflow name 24 + Workflow string `json:"workflow" cborgen:"workflow"` 25 + } 26 + 27 + // PipelineCancelPipeline calls the XRPC method "sh.tangled.pipeline.cancelPipeline". 28 + func PipelineCancelPipeline(ctx context.Context, c util.LexClient, input *PipelineCancelPipeline_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.pipeline.cancelPipeline", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+12 -9
api/tangled/repopull.go
··· 17 } // 18 // RECORDTYPE: RepoPull 19 type RepoPull struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 - Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 - Patch string `json:"patch" cborgen:"patch"` 25 - References []string `json:"references,omitempty" cborgen:"references,omitempty"` 26 - Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 27 - Target *RepoPull_Target `json:"target" cborgen:"target"` 28 - Title string `json:"title" cborgen:"title"` 29 } 30 31 // RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
··· 17 } // 18 // RECORDTYPE: RepoPull 19 type RepoPull struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 + Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 + // patch: (deprecated) use patchBlob instead 25 + Patch *string `json:"patch,omitempty" cborgen:"patch,omitempty"` 26 + // patchBlob: patch content 27 + PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"` 28 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 29 + Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 30 + Target *RepoPull_Target `json:"target" cborgen:"target"` 31 + Title string `json:"title" cborgen:"title"` 32 } 33 34 // RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
-56
appview/db/issues.go
··· 295 return GetIssuesPaginated(e, pagination.Page{}, filters...) 296 } 297 298 - // GetIssueIDs gets list of all existing issue's IDs 299 - func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) { 300 - var ids []int64 301 - 302 - var filters []orm.Filter 303 - openValue := 0 304 - if opts.IsOpen { 305 - openValue = 1 306 - } 307 - filters = append(filters, orm.FilterEq("open", openValue)) 308 - if opts.RepoAt != "" { 309 - filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt)) 310 - } 311 - 312 - var conditions []string 313 - var args []any 314 - 315 - for _, filter := range filters { 316 - conditions = append(conditions, filter.Condition()) 317 - args = append(args, filter.Arg()...) 318 - } 319 - 320 - whereClause := "" 321 - if conditions != nil { 322 - whereClause = " where " + strings.Join(conditions, " and ") 323 - } 324 - query := fmt.Sprintf( 325 - ` 326 - select 327 - id 328 - from 329 - issues 330 - %s 331 - limit ? offset ?`, 332 - whereClause, 333 - ) 334 - args = append(args, opts.Page.Limit, opts.Page.Offset) 335 - rows, err := e.Query(query, args...) 336 - if err != nil { 337 - return nil, err 338 - } 339 - defer rows.Close() 340 - 341 - for rows.Next() { 342 - var id int64 343 - err := rows.Scan(&id) 344 - if err != nil { 345 - return nil, err 346 - } 347 - 348 - ids = append(ids, id) 349 - } 350 - 351 - return ids, nil 352 - } 353 - 354 func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 355 result, err := tx.Exec( 356 `insert into issue_comments (
··· 295 return GetIssuesPaginated(e, pagination.Page{}, filters...) 296 } 297 298 func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 299 result, err := tx.Exec( 300 `insert into issue_comments (
+6 -6
appview/db/pipeline.go
··· 6 "strings" 7 "time" 8 9 "tangled.org/core/appview/models" 10 "tangled.org/core/orm" 11 ) ··· 216 } 217 defer rows.Close() 218 219 - pipelines := make(map[string]models.Pipeline) 220 for rows.Next() { 221 var p models.Pipeline 222 var t models.Trigger ··· 253 p.Trigger = &t 254 p.Statuses = make(map[string]models.WorkflowStatus) 255 256 - k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey) 257 - pipelines[k] = p 258 } 259 260 // get all statuses ··· 314 return nil, fmt.Errorf("invalid status created timestamp %q: %w", created, err) 315 } 316 317 - key := fmt.Sprintf("%s/%s", ps.PipelineKnot, ps.PipelineRkey) 318 319 // extract 320 - pipeline, ok := pipelines[key] 321 if !ok { 322 continue 323 } ··· 331 332 // reassign 333 pipeline.Statuses[ps.Workflow] = statuses 334 - pipelines[key] = pipeline 335 } 336 337 var all []models.Pipeline
··· 6 "strings" 7 "time" 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 "tangled.org/core/appview/models" 11 "tangled.org/core/orm" 12 ) ··· 217 } 218 defer rows.Close() 219 220 + pipelines := make(map[syntax.ATURI]models.Pipeline) 221 for rows.Next() { 222 var p models.Pipeline 223 var t models.Trigger ··· 254 p.Trigger = &t 255 p.Statuses = make(map[string]models.WorkflowStatus) 256 257 + pipelines[p.AtUri()] = p 258 } 259 260 // get all statuses ··· 314 return nil, fmt.Errorf("invalid status created timestamp %q: %w", created, err) 315 } 316 317 + pipelineAt := ps.PipelineAt() 318 319 // extract 320 + pipeline, ok := pipelines[pipelineAt] 321 if !ok { 322 continue 323 } ··· 331 332 // reassign 333 pipeline.Statuses[ps.Workflow] = statuses 334 + pipelines[pipelineAt] = pipeline 335 } 336 337 var all []models.Pipeline
+18 -11
appview/db/profile.go
··· 20 timeline := models.ProfileTimeline{ 21 ByMonth: make([]models.ByMonth, TimeframeMonths), 22 } 23 - currentMonth := time.Now().Month() 24 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) 25 26 pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) ··· 30 31 // group pulls by month 32 for _, pull := range pulls { 33 - pullMonth := pull.Created.Month() 34 35 - if currentMonth-pullMonth >= TimeframeMonths { 36 // shouldn't happen; but times are weird 37 continue 38 } 39 40 - idx := currentMonth - pullMonth 41 items := &timeline.ByMonth[idx].PullEvents.Items 42 43 *items = append(*items, &pull) ··· 53 } 54 55 for _, issue := range issues { 56 - issueMonth := issue.Created.Month() 57 58 - if currentMonth-issueMonth >= TimeframeMonths { 59 // shouldn't happen; but times are weird 60 continue 61 } 62 63 - idx := currentMonth - issueMonth 64 items := &timeline.ByMonth[idx].IssueEvents.Items 65 66 *items = append(*items, &issue) ··· 77 if repo.Source != "" { 78 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 79 if err != nil { 80 - return nil, err 81 } 82 } 83 84 - repoMonth := repo.Created.Month() 85 86 - if currentMonth-repoMonth >= TimeframeMonths { 87 // shouldn't happen; but times are weird 88 continue 89 } 90 91 - idx := currentMonth - repoMonth 92 93 items := &timeline.ByMonth[idx].RepoEvents 94 *items = append(*items, models.RepoEvent{ ··· 98 } 99 100 return &timeline, nil 101 } 102 103 func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
··· 20 timeline := models.ProfileTimeline{ 21 ByMonth: make([]models.ByMonth, TimeframeMonths), 22 } 23 + now := time.Now() 24 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) 25 26 pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) ··· 30 31 // group pulls by month 32 for _, pull := range pulls { 33 + monthsAgo := monthsBetween(pull.Created, now) 34 35 + if monthsAgo >= TimeframeMonths { 36 // shouldn't happen; but times are weird 37 continue 38 } 39 40 + idx := monthsAgo 41 items := &timeline.ByMonth[idx].PullEvents.Items 42 43 *items = append(*items, &pull) ··· 53 } 54 55 for _, issue := range issues { 56 + monthsAgo := monthsBetween(issue.Created, now) 57 58 + if monthsAgo >= TimeframeMonths { 59 // shouldn't happen; but times are weird 60 continue 61 } 62 63 + idx := monthsAgo 64 items := &timeline.ByMonth[idx].IssueEvents.Items 65 66 *items = append(*items, &issue) ··· 77 if repo.Source != "" { 78 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 79 if err != nil { 80 + // the source repo was not found, skip this bit 81 + log.Println("profile", "err", err) 82 } 83 } 84 85 + monthsAgo := monthsBetween(repo.Created, now) 86 87 + if monthsAgo >= TimeframeMonths { 88 // shouldn't happen; but times are weird 89 continue 90 } 91 92 + idx := monthsAgo 93 94 items := &timeline.ByMonth[idx].RepoEvents 95 *items = append(*items, models.RepoEvent{ ··· 99 } 100 101 return &timeline, nil 102 + } 103 + 104 + func monthsBetween(from, to time.Time) int { 105 + years := to.Year() - from.Year() 106 + months := int(to.Month() - from.Month()) 107 + return years*12 + months 108 } 109 110 func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
+12 -68
appview/db/pulls.go
··· 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 "tangled.org/core/appview/models" 16 "tangled.org/core/orm" 17 ) 18 ··· 119 return pullId - 1, err 120 } 121 122 - func GetPullsWithLimit(e Execer, limit int, filters ...orm.Filter) ([]*models.Pull, error) { 123 pulls := make(map[syntax.ATURI]*models.Pull) 124 125 var conditions []string ··· 133 if conditions != nil { 134 whereClause = " where " + strings.Join(conditions, " and ") 135 } 136 - limitClause := "" 137 - if limit != 0 { 138 - limitClause = fmt.Sprintf(" limit %d ", limit) 139 } 140 141 query := fmt.Sprintf(` ··· 161 order by 162 created desc 163 %s 164 - `, whereClause, limitClause) 165 166 rows, err := e.Query(query, args...) 167 if err != nil { ··· 297 } 298 299 func GetPulls(e Execer, filters ...orm.Filter) ([]*models.Pull, error) { 300 - return GetPullsWithLimit(e, 0, filters...) 301 - } 302 - 303 - func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) { 304 - var ids []int64 305 - 306 - var filters []orm.Filter 307 - filters = append(filters, orm.FilterEq("state", opts.State)) 308 - if opts.RepoAt != "" { 309 - filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt)) 310 - } 311 - 312 - var conditions []string 313 - var args []any 314 - 315 - for _, filter := range filters { 316 - conditions = append(conditions, filter.Condition()) 317 - args = append(args, filter.Arg()...) 318 - } 319 - 320 - whereClause := "" 321 - if conditions != nil { 322 - whereClause = " where " + strings.Join(conditions, " and ") 323 - } 324 - pageClause := "" 325 - if opts.Page.Limit != 0 { 326 - pageClause = fmt.Sprintf( 327 - " limit %d offset %d ", 328 - opts.Page.Limit, 329 - opts.Page.Offset, 330 - ) 331 - } 332 - 333 - query := fmt.Sprintf( 334 - ` 335 - select 336 - id 337 - from 338 - pulls 339 - %s 340 - %s`, 341 - whereClause, 342 - pageClause, 343 - ) 344 - args = append(args, opts.Page.Limit, opts.Page.Offset) 345 - rows, err := e.Query(query, args...) 346 - if err != nil { 347 - return nil, err 348 - } 349 - defer rows.Close() 350 - 351 - for rows.Next() { 352 - var id int64 353 - err := rows.Scan(&id) 354 - if err != nil { 355 - return nil, err 356 - } 357 - 358 - ids = append(ids, id) 359 - } 360 - 361 - return ids, nil 362 } 363 364 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 365 - pulls, err := GetPullsWithLimit(e, 1, orm.FilterEq("repo_at", repoAt), orm.FilterEq("pull_id", pullId)) 366 if err != nil { 367 return nil, err 368 }
··· 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/pagination" 17 "tangled.org/core/orm" 18 ) 19 ··· 120 return pullId - 1, err 121 } 122 123 + func GetPullsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Pull, error) { 124 pulls := make(map[syntax.ATURI]*models.Pull) 125 126 var conditions []string ··· 134 if conditions != nil { 135 whereClause = " where " + strings.Join(conditions, " and ") 136 } 137 + pageClause := "" 138 + if page.Limit != 0 { 139 + pageClause = fmt.Sprintf( 140 + " limit %d offset %d ", 141 + page.Limit, 142 + page.Offset, 143 + ) 144 } 145 146 query := fmt.Sprintf(` ··· 166 order by 167 created desc 168 %s 169 + `, whereClause, pageClause) 170 171 rows, err := e.Query(query, args...) 172 if err != nil { ··· 302 } 303 304 func GetPulls(e Execer, filters ...orm.Filter) ([]*models.Pull, error) { 305 + return GetPullsPaginated(e, pagination.Page{}, filters...) 306 } 307 308 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 309 + pulls, err := GetPullsPaginated(e, pagination.Page{Limit: 1}, orm.FilterEq("repo_at", repoAt), orm.FilterEq("pull_id", pullId)) 310 if err != nil { 311 return nil, err 312 }
+1 -1
appview/db/punchcard.go
··· 78 punch.Count = int(count.Int64) 79 } 80 81 - punchcard.Punches[punch.Date.YearDay()] = punch 82 punchcard.Total += punch.Count 83 } 84
··· 78 punch.Count = int(count.Int64) 79 } 80 81 + punchcard.Punches[punch.Date.YearDay()-1] = punch 82 punchcard.Total += punch.Count 83 } 84
+1
appview/db/repos.go
··· 158 from repo_languages 159 where repo_at in (%s) 160 and is_default_ref = 1 161 ) 162 where rn = 1 163 `,
··· 158 from repo_languages 159 where repo_at in (%s) 160 and is_default_ref = 1 161 + and language <> '' 162 ) 163 where rn = 1 164 `,
+32 -32
appview/issues/issues.go
··· 81 82 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 83 l := rp.logger.With("handler", "RepoSingleIssue") 84 - user := rp.oauth.GetUser(r) 85 f, err := rp.repoResolver.Resolve(r) 86 if err != nil { 87 l.Error("failed to get repo and knot", "err", err) ··· 102 103 userReactions := map[models.ReactionKind]bool{} 104 if user != nil { 105 - userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 106 } 107 108 backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) ··· 143 144 func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 145 l := rp.logger.With("handler", "EditIssue") 146 - user := rp.oauth.GetUser(r) 147 148 issue, ok := r.Context().Value("issue").(*models.Issue) 149 if !ok { ··· 182 return 183 } 184 185 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 186 if err != nil { 187 l.Error("failed to get record", "err", err) 188 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") ··· 191 192 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 193 Collection: tangled.RepoIssueNSID, 194 - Repo: user.Did, 195 Rkey: newIssue.Rkey, 196 SwapRecord: ex.Cid, 197 Record: &lexutil.LexiconTypeDecoder{ ··· 292 293 func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 294 l := rp.logger.With("handler", "CloseIssue") 295 - user := rp.oauth.GetUser(r) 296 f, err := rp.repoResolver.Resolve(r) 297 if err != nil { 298 l.Error("failed to get repo and knot", "err", err) ··· 306 return 307 } 308 309 - roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 310 isRepoOwner := roles.IsOwner() 311 isCollaborator := roles.IsCollaborator() 312 - isIssueOwner := user.Did == issue.Did 313 314 // TODO: make this more granular 315 if isIssueOwner || isRepoOwner || isCollaborator { ··· 326 issue.Open = false 327 328 // notify about the issue closure 329 - rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 330 331 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 332 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) ··· 340 341 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 342 l := rp.logger.With("handler", "ReopenIssue") 343 - user := rp.oauth.GetUser(r) 344 f, err := rp.repoResolver.Resolve(r) 345 if err != nil { 346 l.Error("failed to get repo and knot", "err", err) ··· 354 return 355 } 356 357 - roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 358 isRepoOwner := roles.IsOwner() 359 isCollaborator := roles.IsCollaborator() 360 - isIssueOwner := user.Did == issue.Did 361 362 if isCollaborator || isRepoOwner || isIssueOwner { 363 err := db.ReopenIssues( ··· 373 issue.Open = true 374 375 // notify about the issue reopen 376 - rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 377 378 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 379 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) ··· 387 388 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 389 l := rp.logger.With("handler", "NewIssueComment") 390 - user := rp.oauth.GetUser(r) 391 f, err := rp.repoResolver.Resolve(r) 392 if err != nil { 393 l.Error("failed to get repo and knot", "err", err) ··· 416 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 417 418 comment := models.IssueComment{ 419 - Did: user.Did, 420 Rkey: tid.TID(), 421 IssueAt: issue.AtUri().String(), 422 ReplyTo: replyTo, ··· 495 496 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 497 l := rp.logger.With("handler", "IssueComment") 498 - user := rp.oauth.GetUser(r) 499 500 issue, ok := r.Context().Value("issue").(*models.Issue) 501 if !ok { ··· 531 532 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 533 l := rp.logger.With("handler", "EditIssueComment") 534 - user := rp.oauth.GetUser(r) 535 536 issue, ok := r.Context().Value("issue").(*models.Issue) 537 if !ok { ··· 557 } 558 comment := comments[0] 559 560 - if comment.Did != user.Did { 561 - l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 562 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 563 return 564 } ··· 608 // rkey is optional, it was introduced later 609 if newComment.Rkey != "" { 610 // update the record on pds 611 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 612 if err != nil { 613 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 614 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") ··· 617 618 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 619 Collection: tangled.RepoIssueCommentNSID, 620 - Repo: user.Did, 621 Rkey: newComment.Rkey, 622 SwapRecord: ex.Cid, 623 Record: &lexutil.LexiconTypeDecoder{ ··· 641 642 func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 643 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 644 - user := rp.oauth.GetUser(r) 645 646 issue, ok := r.Context().Value("issue").(*models.Issue) 647 if !ok { ··· 677 678 func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 679 l := rp.logger.With("handler", "ReplyIssueComment") 680 - user := rp.oauth.GetUser(r) 681 682 issue, ok := r.Context().Value("issue").(*models.Issue) 683 if !ok { ··· 713 714 func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 715 l := rp.logger.With("handler", "DeleteIssueComment") 716 - user := rp.oauth.GetUser(r) 717 718 issue, ok := r.Context().Value("issue").(*models.Issue) 719 if !ok { ··· 739 } 740 comment := comments[0] 741 742 - if comment.Did != user.Did { 743 - l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 744 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 745 return 746 } ··· 769 } 770 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 771 Collection: tangled.RepoIssueCommentNSID, 772 - Repo: user.Did, 773 Rkey: comment.Rkey, 774 }) 775 if err != nil { ··· 807 808 page := pagination.FromContext(r.Context()) 809 810 - user := rp.oauth.GetUser(r) 811 f, err := rp.repoResolver.Resolve(r) 812 if err != nil { 813 l.Error("failed to get repo and knot", "err", err) ··· 884 } 885 886 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 887 - LoggedInUser: rp.oauth.GetUser(r), 888 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 889 Issues: issues, 890 IssueCount: totalIssues, ··· 897 898 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 899 l := rp.logger.With("handler", "NewIssue") 900 - user := rp.oauth.GetUser(r) 901 902 f, err := rp.repoResolver.Resolve(r) 903 if err != nil { ··· 921 Title: r.FormValue("title"), 922 Body: body, 923 Open: true, 924 - Did: user.Did, 925 Created: time.Now(), 926 Mentions: mentions, 927 References: references, ··· 945 } 946 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 947 Collection: tangled.RepoIssueNSID, 948 - Repo: user.Did, 949 Rkey: issue.Rkey, 950 Record: &lexutil.LexiconTypeDecoder{ 951 Val: &record,
··· 81 82 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 83 l := rp.logger.With("handler", "RepoSingleIssue") 84 + user := rp.oauth.GetMultiAccountUser(r) 85 f, err := rp.repoResolver.Resolve(r) 86 if err != nil { 87 l.Error("failed to get repo and knot", "err", err) ··· 102 103 userReactions := map[models.ReactionKind]bool{} 104 if user != nil { 105 + userReactions = db.GetReactionStatusMap(rp.db, user.Active.Did, issue.AtUri()) 106 } 107 108 backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) ··· 143 144 func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 145 l := rp.logger.With("handler", "EditIssue") 146 + user := rp.oauth.GetMultiAccountUser(r) 147 148 issue, ok := r.Context().Value("issue").(*models.Issue) 149 if !ok { ··· 182 return 183 } 184 185 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Active.Did, newIssue.Rkey) 186 if err != nil { 187 l.Error("failed to get record", "err", err) 188 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") ··· 191 192 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 193 Collection: tangled.RepoIssueNSID, 194 + Repo: user.Active.Did, 195 Rkey: newIssue.Rkey, 196 SwapRecord: ex.Cid, 197 Record: &lexutil.LexiconTypeDecoder{ ··· 292 293 func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 294 l := rp.logger.With("handler", "CloseIssue") 295 + user := rp.oauth.GetMultiAccountUser(r) 296 f, err := rp.repoResolver.Resolve(r) 297 if err != nil { 298 l.Error("failed to get repo and knot", "err", err) ··· 306 return 307 } 308 309 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 310 isRepoOwner := roles.IsOwner() 311 isCollaborator := roles.IsCollaborator() 312 + isIssueOwner := user.Active.Did == issue.Did 313 314 // TODO: make this more granular 315 if isIssueOwner || isRepoOwner || isCollaborator { ··· 326 issue.Open = false 327 328 // notify about the issue closure 329 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Active.Did), issue) 330 331 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 332 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) ··· 340 341 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 342 l := rp.logger.With("handler", "ReopenIssue") 343 + user := rp.oauth.GetMultiAccountUser(r) 344 f, err := rp.repoResolver.Resolve(r) 345 if err != nil { 346 l.Error("failed to get repo and knot", "err", err) ··· 354 return 355 } 356 357 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 358 isRepoOwner := roles.IsOwner() 359 isCollaborator := roles.IsCollaborator() 360 + isIssueOwner := user.Active.Did == issue.Did 361 362 if isCollaborator || isRepoOwner || isIssueOwner { 363 err := db.ReopenIssues( ··· 373 issue.Open = true 374 375 // notify about the issue reopen 376 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Active.Did), issue) 377 378 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 379 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) ··· 387 388 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 389 l := rp.logger.With("handler", "NewIssueComment") 390 + user := rp.oauth.GetMultiAccountUser(r) 391 f, err := rp.repoResolver.Resolve(r) 392 if err != nil { 393 l.Error("failed to get repo and knot", "err", err) ··· 416 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 417 418 comment := models.IssueComment{ 419 + Did: user.Active.Did, 420 Rkey: tid.TID(), 421 IssueAt: issue.AtUri().String(), 422 ReplyTo: replyTo, ··· 495 496 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 497 l := rp.logger.With("handler", "IssueComment") 498 + user := rp.oauth.GetMultiAccountUser(r) 499 500 issue, ok := r.Context().Value("issue").(*models.Issue) 501 if !ok { ··· 531 532 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 533 l := rp.logger.With("handler", "EditIssueComment") 534 + user := rp.oauth.GetMultiAccountUser(r) 535 536 issue, ok := r.Context().Value("issue").(*models.Issue) 537 if !ok { ··· 557 } 558 comment := comments[0] 559 560 + if comment.Did != user.Active.Did { 561 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) 562 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 563 return 564 } ··· 608 // rkey is optional, it was introduced later 609 if newComment.Rkey != "" { 610 // update the record on pds 611 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Active.Did, comment.Rkey) 612 if err != nil { 613 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 614 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") ··· 617 618 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 619 Collection: tangled.RepoIssueCommentNSID, 620 + Repo: user.Active.Did, 621 Rkey: newComment.Rkey, 622 SwapRecord: ex.Cid, 623 Record: &lexutil.LexiconTypeDecoder{ ··· 641 642 func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 643 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 644 + user := rp.oauth.GetMultiAccountUser(r) 645 646 issue, ok := r.Context().Value("issue").(*models.Issue) 647 if !ok { ··· 677 678 func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 679 l := rp.logger.With("handler", "ReplyIssueComment") 680 + user := rp.oauth.GetMultiAccountUser(r) 681 682 issue, ok := r.Context().Value("issue").(*models.Issue) 683 if !ok { ··· 713 714 func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 715 l := rp.logger.With("handler", "DeleteIssueComment") 716 + user := rp.oauth.GetMultiAccountUser(r) 717 718 issue, ok := r.Context().Value("issue").(*models.Issue) 719 if !ok { ··· 739 } 740 comment := comments[0] 741 742 + if comment.Did != user.Active.Did { 743 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did) 744 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 745 return 746 } ··· 769 } 770 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 771 Collection: tangled.RepoIssueCommentNSID, 772 + Repo: user.Active.Did, 773 Rkey: comment.Rkey, 774 }) 775 if err != nil { ··· 807 808 page := pagination.FromContext(r.Context()) 809 810 + user := rp.oauth.GetMultiAccountUser(r) 811 f, err := rp.repoResolver.Resolve(r) 812 if err != nil { 813 l.Error("failed to get repo and knot", "err", err) ··· 884 } 885 886 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 887 + LoggedInUser: rp.oauth.GetMultiAccountUser(r), 888 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 889 Issues: issues, 890 IssueCount: totalIssues, ··· 897 898 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 899 l := rp.logger.With("handler", "NewIssue") 900 + user := rp.oauth.GetMultiAccountUser(r) 901 902 f, err := rp.repoResolver.Resolve(r) 903 if err != nil { ··· 921 Title: r.FormValue("title"), 922 Body: body, 923 Open: true, 924 + Did: user.Active.Did, 925 Created: time.Now(), 926 Mentions: mentions, 927 References: references, ··· 945 } 946 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 947 Collection: tangled.RepoIssueNSID, 948 + Repo: user.Active.Did, 949 Rkey: issue.Rkey, 950 Record: &lexutil.LexiconTypeDecoder{ 951 Val: &record,
+2 -2
appview/issues/opengraph.go
··· 193 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 194 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 195 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 196 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 197 if err != nil { 198 - log.Printf("dolly silhouette not available (this is ok): %v", err) 199 } 200 201 // Draw "opened by @author" and date at the bottom with more spacing
··· 193 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 194 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 195 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 196 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 197 if err != nil { 198 + log.Printf("dolly not available (this is ok): %v", err) 199 } 200 201 // Draw "opened by @author" and date at the bottom with more spacing
+31 -36
appview/knots/knots.go
··· 70 } 71 72 func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 73 - user := k.OAuth.GetUser(r) 74 registrations, err := db.GetRegistrations( 75 k.Db, 76 - orm.FilterEq("did", user.Did), 77 ) 78 if err != nil { 79 k.Logger.Error("failed to fetch knot registrations", "err", err) ··· 92 func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 93 l := k.Logger.With("handler", "dashboard") 94 95 - user := k.OAuth.GetUser(r) 96 - l = l.With("user", user.Did) 97 98 domain := chi.URLParam(r, "domain") 99 if domain == "" { ··· 103 104 registrations, err := db.GetRegistrations( 105 k.Db, 106 - orm.FilterEq("did", user.Did), 107 orm.FilterEq("domain", domain), 108 ) 109 if err != nil { ··· 154 } 155 156 func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 157 - user := k.OAuth.GetUser(r) 158 l := k.Logger.With("handler", "register") 159 160 noticeId := "register-error" ··· 175 return 176 } 177 l = l.With("domain", domain) 178 - l = l.With("user", user.Did) 179 180 tx, err := k.Db.Begin() 181 if err != nil { ··· 188 k.Enforcer.E.LoadPolicy() 189 }() 190 191 - err = db.AddKnot(tx, domain, user.Did) 192 if err != nil { 193 l.Error("failed to insert", "err", err) 194 fail() ··· 210 return 211 } 212 213 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 214 var exCid *string 215 if ex != nil { 216 exCid = ex.Cid ··· 219 // re-announce by registering under same rkey 220 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 221 Collection: tangled.KnotNSID, 222 - Repo: user.Did, 223 Rkey: domain, 224 Record: &lexutil.LexiconTypeDecoder{ 225 Val: &tangled.Knot{ ··· 250 } 251 252 // begin verification 253 - err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 254 if err != nil { 255 l.Error("verification failed", "err", err) 256 k.Pages.HxRefresh(w) 257 return 258 } 259 260 - err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 261 if err != nil { 262 l.Error("failed to mark verified", "err", err) 263 k.Pages.HxRefresh(w) ··· 275 } 276 277 func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 278 - user := k.OAuth.GetUser(r) 279 l := k.Logger.With("handler", "delete") 280 281 noticeId := "operation-error" ··· 294 // get record from db first 295 registrations, err := db.GetRegistrations( 296 k.Db, 297 - orm.FilterEq("did", user.Did), 298 orm.FilterEq("domain", domain), 299 ) 300 if err != nil { ··· 322 323 err = db.DeleteKnot( 324 tx, 325 - orm.FilterEq("did", user.Did), 326 orm.FilterEq("domain", domain), 327 ) 328 if err != nil { ··· 350 351 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 352 Collection: tangled.KnotNSID, 353 - Repo: user.Did, 354 Rkey: domain, 355 }) 356 if err != nil { ··· 382 } 383 384 func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 385 - user := k.OAuth.GetUser(r) 386 l := k.Logger.With("handler", "retry") 387 388 noticeId := "operation-error" ··· 398 return 399 } 400 l = l.With("domain", domain) 401 - l = l.With("user", user.Did) 402 403 // get record from db first 404 registrations, err := db.GetRegistrations( 405 k.Db, 406 - orm.FilterEq("did", user.Did), 407 orm.FilterEq("domain", domain), 408 ) 409 if err != nil { ··· 419 registration := registrations[0] 420 421 // begin verification 422 - err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 423 if err != nil { 424 l.Error("verification failed", "err", err) 425 ··· 437 return 438 } 439 440 - err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 441 if err != nil { 442 l.Error("failed to mark verified", "err", err) 443 k.Pages.Notice(w, noticeId, err.Error()) ··· 456 return 457 } 458 459 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 460 var exCid *string 461 if ex != nil { 462 exCid = ex.Cid ··· 465 // ignore the error here 466 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 467 Collection: tangled.KnotNSID, 468 - Repo: user.Did, 469 Rkey: domain, 470 Record: &lexutil.LexiconTypeDecoder{ 471 Val: &tangled.Knot{ ··· 494 // Get updated registration to show 495 registrations, err = db.GetRegistrations( 496 k.Db, 497 - orm.FilterEq("did", user.Did), 498 orm.FilterEq("domain", domain), 499 ) 500 if err != nil { ··· 516 } 517 518 func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 519 - user := k.OAuth.GetUser(r) 520 l := k.Logger.With("handler", "addMember") 521 522 domain := chi.URLParam(r, "domain") ··· 526 return 527 } 528 l = l.With("domain", domain) 529 - l = l.With("user", user.Did) 530 531 registrations, err := db.GetRegistrations( 532 k.Db, 533 - orm.FilterEq("did", user.Did), 534 orm.FilterEq("domain", domain), 535 orm.FilterIsNot("registered", "null"), 536 ) ··· 583 584 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 585 Collection: tangled.KnotMemberNSID, 586 - Repo: user.Did, 587 Rkey: rkey, 588 Record: &lexutil.LexiconTypeDecoder{ 589 Val: &tangled.KnotMember{ ··· 618 } 619 620 func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 621 - user := k.OAuth.GetUser(r) 622 l := k.Logger.With("handler", "removeMember") 623 624 noticeId := "operation-error" ··· 634 return 635 } 636 l = l.With("domain", domain) 637 - l = l.With("user", user.Did) 638 639 registrations, err := db.GetRegistrations( 640 k.Db, 641 - orm.FilterEq("did", user.Did), 642 orm.FilterEq("domain", domain), 643 orm.FilterIsNot("registered", "null"), 644 ) ··· 663 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 664 if err != nil { 665 l.Error("failed to resolve member identity to handle", "err", err) 666 - k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 667 - return 668 - } 669 - if memberId.Handle.IsInvalidHandle() { 670 - l.Error("failed to resolve member identity to handle") 671 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 672 return 673 }
··· 70 } 71 72 func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 73 + user := k.OAuth.GetMultiAccountUser(r) 74 registrations, err := db.GetRegistrations( 75 k.Db, 76 + orm.FilterEq("did", user.Active.Did), 77 ) 78 if err != nil { 79 k.Logger.Error("failed to fetch knot registrations", "err", err) ··· 92 func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 93 l := k.Logger.With("handler", "dashboard") 94 95 + user := k.OAuth.GetMultiAccountUser(r) 96 + l = l.With("user", user.Active.Did) 97 98 domain := chi.URLParam(r, "domain") 99 if domain == "" { ··· 103 104 registrations, err := db.GetRegistrations( 105 k.Db, 106 + orm.FilterEq("did", user.Active.Did), 107 orm.FilterEq("domain", domain), 108 ) 109 if err != nil { ··· 154 } 155 156 func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 157 + user := k.OAuth.GetMultiAccountUser(r) 158 l := k.Logger.With("handler", "register") 159 160 noticeId := "register-error" ··· 175 return 176 } 177 l = l.With("domain", domain) 178 + l = l.With("user", user.Active.Did) 179 180 tx, err := k.Db.Begin() 181 if err != nil { ··· 188 k.Enforcer.E.LoadPolicy() 189 }() 190 191 + err = db.AddKnot(tx, domain, user.Active.Did) 192 if err != nil { 193 l.Error("failed to insert", "err", err) 194 fail() ··· 210 return 211 } 212 213 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Active.Did, domain) 214 var exCid *string 215 if ex != nil { 216 exCid = ex.Cid ··· 219 // re-announce by registering under same rkey 220 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 221 Collection: tangled.KnotNSID, 222 + Repo: user.Active.Did, 223 Rkey: domain, 224 Record: &lexutil.LexiconTypeDecoder{ 225 Val: &tangled.Knot{ ··· 250 } 251 252 // begin verification 253 + err = serververify.RunVerification(r.Context(), domain, user.Active.Did, k.Config.Core.Dev) 254 if err != nil { 255 l.Error("verification failed", "err", err) 256 k.Pages.HxRefresh(w) 257 return 258 } 259 260 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Active.Did) 261 if err != nil { 262 l.Error("failed to mark verified", "err", err) 263 k.Pages.HxRefresh(w) ··· 275 } 276 277 func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 278 + user := k.OAuth.GetMultiAccountUser(r) 279 l := k.Logger.With("handler", "delete") 280 281 noticeId := "operation-error" ··· 294 // get record from db first 295 registrations, err := db.GetRegistrations( 296 k.Db, 297 + orm.FilterEq("did", user.Active.Did), 298 orm.FilterEq("domain", domain), 299 ) 300 if err != nil { ··· 322 323 err = db.DeleteKnot( 324 tx, 325 + orm.FilterEq("did", user.Active.Did), 326 orm.FilterEq("domain", domain), 327 ) 328 if err != nil { ··· 350 351 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 352 Collection: tangled.KnotNSID, 353 + Repo: user.Active.Did, 354 Rkey: domain, 355 }) 356 if err != nil { ··· 382 } 383 384 func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 385 + user := k.OAuth.GetMultiAccountUser(r) 386 l := k.Logger.With("handler", "retry") 387 388 noticeId := "operation-error" ··· 398 return 399 } 400 l = l.With("domain", domain) 401 + l = l.With("user", user.Active.Did) 402 403 // get record from db first 404 registrations, err := db.GetRegistrations( 405 k.Db, 406 + orm.FilterEq("did", user.Active.Did), 407 orm.FilterEq("domain", domain), 408 ) 409 if err != nil { ··· 419 registration := registrations[0] 420 421 // begin verification 422 + err = serververify.RunVerification(r.Context(), domain, user.Active.Did, k.Config.Core.Dev) 423 if err != nil { 424 l.Error("verification failed", "err", err) 425 ··· 437 return 438 } 439 440 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Active.Did) 441 if err != nil { 442 l.Error("failed to mark verified", "err", err) 443 k.Pages.Notice(w, noticeId, err.Error()) ··· 456 return 457 } 458 459 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Active.Did, domain) 460 var exCid *string 461 if ex != nil { 462 exCid = ex.Cid ··· 465 // ignore the error here 466 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 467 Collection: tangled.KnotNSID, 468 + Repo: user.Active.Did, 469 Rkey: domain, 470 Record: &lexutil.LexiconTypeDecoder{ 471 Val: &tangled.Knot{ ··· 494 // Get updated registration to show 495 registrations, err = db.GetRegistrations( 496 k.Db, 497 + orm.FilterEq("did", user.Active.Did), 498 orm.FilterEq("domain", domain), 499 ) 500 if err != nil { ··· 516 } 517 518 func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 519 + user := k.OAuth.GetMultiAccountUser(r) 520 l := k.Logger.With("handler", "addMember") 521 522 domain := chi.URLParam(r, "domain") ··· 526 return 527 } 528 l = l.With("domain", domain) 529 + l = l.With("user", user.Active.Did) 530 531 registrations, err := db.GetRegistrations( 532 k.Db, 533 + orm.FilterEq("did", user.Active.Did), 534 orm.FilterEq("domain", domain), 535 orm.FilterIsNot("registered", "null"), 536 ) ··· 583 584 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 585 Collection: tangled.KnotMemberNSID, 586 + Repo: user.Active.Did, 587 Rkey: rkey, 588 Record: &lexutil.LexiconTypeDecoder{ 589 Val: &tangled.KnotMember{ ··· 618 } 619 620 func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 621 + user := k.OAuth.GetMultiAccountUser(r) 622 l := k.Logger.With("handler", "removeMember") 623 624 noticeId := "operation-error" ··· 634 return 635 } 636 l = l.With("domain", domain) 637 + l = l.With("user", user.Active.Did) 638 639 registrations, err := db.GetRegistrations( 640 k.Db, 641 + orm.FilterEq("did", user.Active.Did), 642 orm.FilterEq("domain", domain), 643 orm.FilterIsNot("registered", "null"), 644 ) ··· 663 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 664 if err != nil { 665 l.Error("failed to resolve member identity to handle", "err", err) 666 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 667 return 668 }
+2 -2
appview/labels/labels.go
··· 68 // - this handler should calculate the diff in order to create the labelop record 69 // - we need the diff in order to maintain a "history" of operations performed by users 70 func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { 71 - user := l.oauth.GetUser(r) 72 73 noticeId := "add-label-error" 74 ··· 82 return 83 } 84 85 - did := user.Did 86 rkey := tid.TID() 87 performedAt := time.Now() 88 indexedAt := time.Now()
··· 68 // - this handler should calculate the diff in order to create the labelop record 69 // - we need the diff in order to maintain a "history" of operations performed by users 70 func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { 71 + user := l.oauth.GetMultiAccountUser(r) 72 73 noticeId := "add-label-error" 74 ··· 82 return 83 } 84 85 + did := user.Active.Did 86 rkey := tid.TID() 87 performedAt := time.Now() 88 indexedAt := time.Now()
+10 -8
appview/middleware/middleware.go
··· 115 return func(next http.Handler) http.Handler { 116 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 117 // requires auth also 118 - actor := mw.oauth.GetUser(r) 119 if actor == nil { 120 // we need a logged in user 121 log.Printf("not logged in, redirecting") ··· 128 return 129 } 130 131 - ok, err := mw.enforcer.E.HasGroupingPolicy(actor.Did, group, domain) 132 if err != nil || !ok { 133 - // we need a logged in user 134 - log.Printf("%s does not have perms of a %s in domain %s", actor.Did, group, domain) 135 http.Error(w, "Forbiden", http.StatusUnauthorized) 136 return 137 } ··· 149 return func(next http.Handler) http.Handler { 150 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 151 // requires auth also 152 - actor := mw.oauth.GetUser(r) 153 if actor == nil { 154 // we need a logged in user 155 log.Printf("not logged in, redirecting") ··· 162 return 163 } 164 165 - ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 166 if err != nil || !ok { 167 - // we need a logged in user 168 - log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.DidSlashRepo()) 169 http.Error(w, "Forbiden", http.StatusUnauthorized) 170 return 171 } ··· 223 ) 224 if err != nil { 225 log.Println("failed to resolve repo", "err", err) 226 mw.pages.ErrorKnot404(w) 227 return 228 } ··· 240 f, err := mw.repoResolver.Resolve(r) 241 if err != nil { 242 log.Println("failed to fully resolve repo", err) 243 mw.pages.ErrorKnot404(w) 244 return 245 } ··· 288 f, err := mw.repoResolver.Resolve(r) 289 if err != nil { 290 log.Println("failed to fully resolve repo", err) 291 mw.pages.ErrorKnot404(w) 292 return 293 } ··· 324 f, err := mw.repoResolver.Resolve(r) 325 if err != nil { 326 log.Println("failed to fully resolve repo", err) 327 mw.pages.ErrorKnot404(w) 328 return 329 }
··· 115 return func(next http.Handler) http.Handler { 116 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 117 // requires auth also 118 + actor := mw.oauth.GetMultiAccountUser(r) 119 if actor == nil { 120 // we need a logged in user 121 log.Printf("not logged in, redirecting") ··· 128 return 129 } 130 131 + ok, err := mw.enforcer.E.HasGroupingPolicy(actor.Active.Did, group, domain) 132 if err != nil || !ok { 133 + log.Printf("%s does not have perms of a %s in domain %s", actor.Active.Did, group, domain) 134 http.Error(w, "Forbiden", http.StatusUnauthorized) 135 return 136 } ··· 148 return func(next http.Handler) http.Handler { 149 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 150 // requires auth also 151 + actor := mw.oauth.GetMultiAccountUser(r) 152 if actor == nil { 153 // we need a logged in user 154 log.Printf("not logged in, redirecting") ··· 161 return 162 } 163 164 + ok, err := mw.enforcer.E.Enforce(actor.Active.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 165 if err != nil || !ok { 166 + log.Printf("%s does not have perms of a %s in repo %s", actor.Active.Did, requiredPerm, f.DidSlashRepo()) 167 http.Error(w, "Forbiden", http.StatusUnauthorized) 168 return 169 } ··· 221 ) 222 if err != nil { 223 log.Println("failed to resolve repo", "err", err) 224 + w.WriteHeader(http.StatusNotFound) 225 mw.pages.ErrorKnot404(w) 226 return 227 } ··· 239 f, err := mw.repoResolver.Resolve(r) 240 if err != nil { 241 log.Println("failed to fully resolve repo", err) 242 + w.WriteHeader(http.StatusNotFound) 243 mw.pages.ErrorKnot404(w) 244 return 245 } ··· 288 f, err := mw.repoResolver.Resolve(r) 289 if err != nil { 290 log.Println("failed to fully resolve repo", err) 291 + w.WriteHeader(http.StatusNotFound) 292 mw.pages.ErrorKnot404(w) 293 return 294 } ··· 325 f, err := mw.repoResolver.Resolve(r) 326 if err != nil { 327 log.Println("failed to fully resolve repo", err) 328 + w.WriteHeader(http.StatusNotFound) 329 mw.pages.ErrorKnot404(w) 330 return 331 }
+48
appview/models/pipeline.go
··· 1 package models 2 3 import ( 4 "slices" 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 "github.com/go-git/go-git/v5/plumbing" 9 spindle "tangled.org/core/spindle/models" 10 "tangled.org/core/workflow" 11 ) ··· 25 Statuses map[string]WorkflowStatus 26 } 27 28 type WorkflowStatus struct { 29 Data []PipelineStatus 30 } ··· 52 return 0 53 } 54 55 func (p Pipeline) Counts() map[string]int { 56 m := make(map[string]int) 57 for _, w := range p.Statuses { ··· 128 Error *string 129 ExitCode int 130 }
··· 1 package models 2 3 import ( 4 + "fmt" 5 "slices" 6 + "strings" 7 "time" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "github.com/go-git/go-git/v5/plumbing" 11 + "tangled.org/core/api/tangled" 12 spindle "tangled.org/core/spindle/models" 13 "tangled.org/core/workflow" 14 ) ··· 28 Statuses map[string]WorkflowStatus 29 } 30 31 + func (p *Pipeline) AtUri() syntax.ATURI { 32 + return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", p.Knot, tangled.PipelineNSID, p.Rkey)) 33 + } 34 + 35 type WorkflowStatus struct { 36 Data []PipelineStatus 37 } ··· 59 return 0 60 } 61 62 + // produces short summary of successes: 63 + // - "0/4" when zero successes of 4 workflows 64 + // - "4/4" when all successes of 4 workflows 65 + // - "0/0" when no workflows run in this pipeline 66 + func (p Pipeline) ShortStatusSummary() string { 67 + counts := make(map[spindle.StatusKind]int) 68 + for _, w := range p.Statuses { 69 + counts[w.Latest().Status] += 1 70 + } 71 + 72 + total := len(p.Statuses) 73 + successes := counts[spindle.StatusKindSuccess] 74 + 75 + return fmt.Sprintf("%d/%d", successes, total) 76 + } 77 + 78 + // produces a string of the form "3/4 success, 2/4 failed, 1/4 pending" 79 + func (p Pipeline) LongStatusSummary() string { 80 + counts := make(map[spindle.StatusKind]int) 81 + for _, w := range p.Statuses { 82 + counts[w.Latest().Status] += 1 83 + } 84 + 85 + total := len(p.Statuses) 86 + 87 + var result []string 88 + // finish states first, followed by start states 89 + states := append(spindle.FinishStates[:], spindle.StartStates[:]...) 90 + for _, state := range states { 91 + if count, ok := counts[state]; ok { 92 + result = append(result, fmt.Sprintf("%d/%d %s", count, total, state.String())) 93 + } 94 + } 95 + 96 + return strings.Join(result, ", ") 97 + } 98 + 99 func (p Pipeline) Counts() map[string]int { 100 m := make(map[string]int) 101 for _, w := range p.Statuses { ··· 172 Error *string 173 ExitCode int 174 } 175 + 176 + func (ps *PipelineStatus) PipelineAt() syntax.ATURI { 177 + return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", ps.PipelineKnot, tangled.PipelineNSID, ps.PipelineRkey)) 178 + }
+8 -18
appview/models/pull.go
··· 83 Repo *Repo 84 } 85 86 func (p Pull) AsRecord() tangled.RepoPull { 87 var source *tangled.RepoPull_Source 88 if p.PullSource != nil { ··· 113 Repo: p.RepoAt.String(), 114 Branch: p.TargetBranch, 115 }, 116 - Patch: p.LatestPatch(), 117 Source: source, 118 } 119 return record ··· 171 return syntax.ATURI(p.CommentAt) 172 } 173 174 - // func (p *PullComment) AsRecord() tangled.RepoPullComment { 175 - // mentions := make([]string, len(p.Mentions)) 176 - // for i, did := range p.Mentions { 177 - // mentions[i] = string(did) 178 - // } 179 - // references := make([]string, len(p.References)) 180 - // for i, uri := range p.References { 181 - // references[i] = string(uri) 182 - // } 183 - // return tangled.RepoPullComment{ 184 - // Pull: p.PullAt, 185 - // Body: p.Body, 186 - // Mentions: mentions, 187 - // References: references, 188 - // CreatedAt: p.Created.Format(time.RFC3339), 189 - // } 190 - // } 191 192 func (p *Pull) LastRoundNumber() int { 193 return len(p.Submissions) - 1
··· 83 Repo *Repo 84 } 85 86 + // NOTE: This method does not include patch blob in returned atproto record 87 func (p Pull) AsRecord() tangled.RepoPull { 88 var source *tangled.RepoPull_Source 89 if p.PullSource != nil { ··· 114 Repo: p.RepoAt.String(), 115 Branch: p.TargetBranch, 116 }, 117 Source: source, 118 } 119 return record ··· 171 return syntax.ATURI(p.CommentAt) 172 } 173 174 + func (p *Pull) TotalComments() int { 175 + total := 0 176 + for _, s := range p.Submissions { 177 + total += len(s.Comments) 178 + } 179 + return total 180 + } 181 182 func (p *Pull) LastRoundNumber() int { 183 return len(p.Submissions) - 1
+7 -6
appview/notifications/notifications.go
··· 48 49 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 50 l := n.logger.With("handler", "notificationsPage") 51 - user := n.oauth.GetUser(r) 52 53 page := pagination.FromContext(r.Context()) 54 55 total, err := db.CountNotifications( 56 n.db, 57 - orm.FilterEq("recipient_did", user.Did), 58 ) 59 if err != nil { 60 l.Error("failed to get total notifications", "err", err) ··· 65 notifications, err := db.GetNotificationsWithEntities( 66 n.db, 67 page, 68 - orm.FilterEq("recipient_did", user.Did), 69 ) 70 if err != nil { 71 l.Error("failed to get notifications", "err", err) ··· 73 return 74 } 75 76 - err = db.MarkAllNotificationsRead(n.db, user.Did) 77 if err != nil { 78 l.Error("failed to mark notifications as read", "err", err) 79 } ··· 90 } 91 92 func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 93 - user := n.oauth.GetUser(r) 94 if user == nil { 95 return 96 } 97 98 count, err := db.CountNotifications( 99 n.db, 100 - orm.FilterEq("recipient_did", user.Did), 101 orm.FilterEq("read", 0), 102 ) 103 if err != nil {
··· 48 49 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 50 l := n.logger.With("handler", "notificationsPage") 51 + user := n.oauth.GetMultiAccountUser(r) 52 53 page := pagination.FromContext(r.Context()) 54 55 total, err := db.CountNotifications( 56 n.db, 57 + orm.FilterEq("recipient_did", user.Active.Did), 58 ) 59 if err != nil { 60 l.Error("failed to get total notifications", "err", err) ··· 65 notifications, err := db.GetNotificationsWithEntities( 66 n.db, 67 page, 68 + orm.FilterEq("recipient_did", user.Active.Did), 69 ) 70 if err != nil { 71 l.Error("failed to get notifications", "err", err) ··· 73 return 74 } 75 76 + err = db.MarkAllNotificationsRead(n.db, user.Active.Did) 77 if err != nil { 78 l.Error("failed to mark notifications as read", "err", err) 79 } ··· 90 } 91 92 func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 93 + user := n.oauth.GetMultiAccountUser(r) 94 if user == nil { 95 + http.Error(w, "Forbidden", http.StatusUnauthorized) 96 return 97 } 98 99 count, err := db.CountNotifications( 100 n.db, 101 + orm.FilterEq("recipient_did", user.Active.Did), 102 orm.FilterEq("read", 0), 103 ) 104 if err != nil {
+191
appview/oauth/accounts.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "net/http" 7 + "time" 8 + ) 9 + 10 + const MaxAccounts = 20 11 + 12 + var ErrMaxAccountsReached = errors.New("maximum number of linked accounts reached") 13 + 14 + type AccountInfo struct { 15 + Did string `json:"did"` 16 + Handle string `json:"handle"` 17 + SessionId string `json:"session_id"` 18 + AddedAt int64 `json:"added_at"` 19 + } 20 + 21 + type AccountRegistry struct { 22 + Accounts []AccountInfo `json:"accounts"` 23 + } 24 + 25 + type MultiAccountUser struct { 26 + Active *User 27 + Accounts []AccountInfo 28 + } 29 + 30 + func (m *MultiAccountUser) Did() string { 31 + if m.Active == nil { 32 + return "" 33 + } 34 + return m.Active.Did 35 + } 36 + 37 + func (m *MultiAccountUser) Pds() string { 38 + if m.Active == nil { 39 + return "" 40 + } 41 + return m.Active.Pds 42 + } 43 + 44 + func (o *OAuth) GetAccounts(r *http.Request) *AccountRegistry { 45 + session, err := o.SessStore.Get(r, AccountsName) 46 + if err != nil || session.IsNew { 47 + return &AccountRegistry{Accounts: []AccountInfo{}} 48 + } 49 + 50 + data, ok := session.Values["accounts"].(string) 51 + if !ok { 52 + return &AccountRegistry{Accounts: []AccountInfo{}} 53 + } 54 + 55 + var registry AccountRegistry 56 + if err := json.Unmarshal([]byte(data), &registry); err != nil { 57 + return &AccountRegistry{Accounts: []AccountInfo{}} 58 + } 59 + 60 + return &registry 61 + } 62 + 63 + func (o *OAuth) SaveAccounts(w http.ResponseWriter, r *http.Request, registry *AccountRegistry) error { 64 + session, err := o.SessStore.Get(r, AccountsName) 65 + if err != nil { 66 + return err 67 + } 68 + 69 + data, err := json.Marshal(registry) 70 + if err != nil { 71 + return err 72 + } 73 + 74 + session.Values["accounts"] = string(data) 75 + session.Options.MaxAge = 60 * 60 * 24 * 365 76 + session.Options.HttpOnly = true 77 + session.Options.Secure = !o.Config.Core.Dev 78 + session.Options.SameSite = http.SameSiteLaxMode 79 + 80 + return session.Save(r, w) 81 + } 82 + 83 + func (r *AccountRegistry) AddAccount(did, handle, sessionId string) error { 84 + for i, acc := range r.Accounts { 85 + if acc.Did == did { 86 + r.Accounts[i].SessionId = sessionId 87 + r.Accounts[i].Handle = handle 88 + return nil 89 + } 90 + } 91 + 92 + if len(r.Accounts) >= MaxAccounts { 93 + return ErrMaxAccountsReached 94 + } 95 + 96 + r.Accounts = append(r.Accounts, AccountInfo{ 97 + Did: did, 98 + Handle: handle, 99 + SessionId: sessionId, 100 + AddedAt: time.Now().Unix(), 101 + }) 102 + return nil 103 + } 104 + 105 + func (r *AccountRegistry) RemoveAccount(did string) { 106 + filtered := make([]AccountInfo, 0, len(r.Accounts)) 107 + for _, acc := range r.Accounts { 108 + if acc.Did != did { 109 + filtered = append(filtered, acc) 110 + } 111 + } 112 + r.Accounts = filtered 113 + } 114 + 115 + func (r *AccountRegistry) FindAccount(did string) *AccountInfo { 116 + for i := range r.Accounts { 117 + if r.Accounts[i].Did == did { 118 + return &r.Accounts[i] 119 + } 120 + } 121 + return nil 122 + } 123 + 124 + func (r *AccountRegistry) OtherAccounts(activeDid string) []AccountInfo { 125 + result := make([]AccountInfo, 0, len(r.Accounts)) 126 + for _, acc := range r.Accounts { 127 + if acc.Did != activeDid { 128 + result = append(result, acc) 129 + } 130 + } 131 + return result 132 + } 133 + 134 + func (o *OAuth) GetMultiAccountUser(r *http.Request) *MultiAccountUser { 135 + user := o.GetUser(r) 136 + if user == nil { 137 + return nil 138 + } 139 + 140 + registry := o.GetAccounts(r) 141 + return &MultiAccountUser{ 142 + Active: user, 143 + Accounts: registry.Accounts, 144 + } 145 + } 146 + 147 + type AuthReturnInfo struct { 148 + ReturnURL string 149 + AddAccount bool 150 + } 151 + 152 + func (o *OAuth) SetAuthReturn(w http.ResponseWriter, r *http.Request, returnURL string, addAccount bool) error { 153 + session, err := o.SessStore.Get(r, AuthReturnName) 154 + if err != nil { 155 + return err 156 + } 157 + 158 + session.Values[AuthReturnURL] = returnURL 159 + session.Values[AuthAddAccount] = addAccount 160 + session.Options.MaxAge = 60 * 30 161 + session.Options.HttpOnly = true 162 + session.Options.Secure = !o.Config.Core.Dev 163 + session.Options.SameSite = http.SameSiteLaxMode 164 + 165 + return session.Save(r, w) 166 + } 167 + 168 + func (o *OAuth) GetAuthReturn(r *http.Request) *AuthReturnInfo { 169 + session, err := o.SessStore.Get(r, AuthReturnName) 170 + if err != nil || session.IsNew { 171 + return &AuthReturnInfo{} 172 + } 173 + 174 + returnURL, _ := session.Values[AuthReturnURL].(string) 175 + addAccount, _ := session.Values[AuthAddAccount].(bool) 176 + 177 + return &AuthReturnInfo{ 178 + ReturnURL: returnURL, 179 + AddAccount: addAccount, 180 + } 181 + } 182 + 183 + func (o *OAuth) ClearAuthReturn(w http.ResponseWriter, r *http.Request) error { 184 + session, err := o.SessStore.Get(r, AuthReturnName) 185 + if err != nil { 186 + return err 187 + } 188 + 189 + session.Options.MaxAge = -1 190 + return session.Save(r, w) 191 + }
+265
appview/oauth/accounts_test.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestAccountRegistry_AddAccount(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + initial []AccountInfo 11 + addDid string 12 + addHandle string 13 + addSessionId string 14 + wantErr error 15 + wantLen int 16 + wantSessionId string 17 + }{ 18 + { 19 + name: "add first account", 20 + initial: []AccountInfo{}, 21 + addDid: "did:plc:abc123", 22 + addHandle: "alice.bsky.social", 23 + addSessionId: "session-1", 24 + wantErr: nil, 25 + wantLen: 1, 26 + wantSessionId: "session-1", 27 + }, 28 + { 29 + name: "add second account", 30 + initial: []AccountInfo{ 31 + {Did: "did:plc:abc123", Handle: "alice.bsky.social", SessionId: "session-1", AddedAt: 1000}, 32 + }, 33 + addDid: "did:plc:def456", 34 + addHandle: "bob.bsky.social", 35 + addSessionId: "session-2", 36 + wantErr: nil, 37 + wantLen: 2, 38 + wantSessionId: "session-2", 39 + }, 40 + { 41 + name: "update existing account session", 42 + initial: []AccountInfo{ 43 + {Did: "did:plc:abc123", Handle: "alice.bsky.social", SessionId: "old-session", AddedAt: 1000}, 44 + }, 45 + addDid: "did:plc:abc123", 46 + addHandle: "alice.bsky.social", 47 + addSessionId: "new-session", 48 + wantErr: nil, 49 + wantLen: 1, 50 + wantSessionId: "new-session", 51 + }, 52 + } 53 + 54 + for _, tt := range tests { 55 + t.Run(tt.name, func(t *testing.T) { 56 + registry := &AccountRegistry{Accounts: tt.initial} 57 + err := registry.AddAccount(tt.addDid, tt.addHandle, tt.addSessionId) 58 + 59 + if err != tt.wantErr { 60 + t.Errorf("AddAccount() error = %v, want %v", err, tt.wantErr) 61 + } 62 + 63 + if len(registry.Accounts) != tt.wantLen { 64 + t.Errorf("AddAccount() len = %d, want %d", len(registry.Accounts), tt.wantLen) 65 + } 66 + 67 + found := registry.FindAccount(tt.addDid) 68 + if found == nil { 69 + t.Errorf("AddAccount() account not found after add") 70 + return 71 + } 72 + 73 + if found.SessionId != tt.wantSessionId { 74 + t.Errorf("AddAccount() sessionId = %s, want %s", found.SessionId, tt.wantSessionId) 75 + } 76 + }) 77 + } 78 + } 79 + 80 + func TestAccountRegistry_AddAccount_MaxLimit(t *testing.T) { 81 + registry := &AccountRegistry{Accounts: make([]AccountInfo, 0, MaxAccounts)} 82 + 83 + for i := range MaxAccounts { 84 + err := registry.AddAccount("did:plc:user"+string(rune('a'+i)), "handle", "session") 85 + if err != nil { 86 + t.Fatalf("AddAccount() unexpected error on account %d: %v", i, err) 87 + } 88 + } 89 + 90 + if len(registry.Accounts) != MaxAccounts { 91 + t.Errorf("expected %d accounts, got %d", MaxAccounts, len(registry.Accounts)) 92 + } 93 + 94 + err := registry.AddAccount("did:plc:overflow", "overflow", "session-overflow") 95 + if err != ErrMaxAccountsReached { 96 + t.Errorf("AddAccount() error = %v, want %v", err, ErrMaxAccountsReached) 97 + } 98 + 99 + if len(registry.Accounts) != MaxAccounts { 100 + t.Errorf("account added despite max limit, got %d", len(registry.Accounts)) 101 + } 102 + } 103 + 104 + func TestAccountRegistry_RemoveAccount(t *testing.T) { 105 + tests := []struct { 106 + name string 107 + initial []AccountInfo 108 + removeDid string 109 + wantLen int 110 + wantDids []string 111 + }{ 112 + { 113 + name: "remove existing account", 114 + initial: []AccountInfo{ 115 + {Did: "did:plc:abc123", Handle: "alice", SessionId: "s1"}, 116 + {Did: "did:plc:def456", Handle: "bob", SessionId: "s2"}, 117 + }, 118 + removeDid: "did:plc:abc123", 119 + wantLen: 1, 120 + wantDids: []string{"did:plc:def456"}, 121 + }, 122 + { 123 + name: "remove non-existing account", 124 + initial: []AccountInfo{ 125 + {Did: "did:plc:abc123", Handle: "alice", SessionId: "s1"}, 126 + }, 127 + removeDid: "did:plc:notfound", 128 + wantLen: 1, 129 + wantDids: []string{"did:plc:abc123"}, 130 + }, 131 + { 132 + name: "remove last account", 133 + initial: []AccountInfo{ 134 + {Did: "did:plc:abc123", Handle: "alice", SessionId: "s1"}, 135 + }, 136 + removeDid: "did:plc:abc123", 137 + wantLen: 0, 138 + wantDids: []string{}, 139 + }, 140 + { 141 + name: "remove from empty registry", 142 + initial: []AccountInfo{}, 143 + removeDid: "did:plc:abc123", 144 + wantLen: 0, 145 + wantDids: []string{}, 146 + }, 147 + } 148 + 149 + for _, tt := range tests { 150 + t.Run(tt.name, func(t *testing.T) { 151 + registry := &AccountRegistry{Accounts: tt.initial} 152 + registry.RemoveAccount(tt.removeDid) 153 + 154 + if len(registry.Accounts) != tt.wantLen { 155 + t.Errorf("RemoveAccount() len = %d, want %d", len(registry.Accounts), tt.wantLen) 156 + } 157 + 158 + for _, wantDid := range tt.wantDids { 159 + if registry.FindAccount(wantDid) == nil { 160 + t.Errorf("RemoveAccount() expected %s to remain", wantDid) 161 + } 162 + } 163 + 164 + if registry.FindAccount(tt.removeDid) != nil && tt.wantLen < len(tt.initial) { 165 + t.Errorf("RemoveAccount() %s should have been removed", tt.removeDid) 166 + } 167 + }) 168 + } 169 + } 170 + 171 + func TestAccountRegistry_FindAccount(t *testing.T) { 172 + registry := &AccountRegistry{ 173 + Accounts: []AccountInfo{ 174 + {Did: "did:plc:first", Handle: "first", SessionId: "s1", AddedAt: 1000}, 175 + {Did: "did:plc:second", Handle: "second", SessionId: "s2", AddedAt: 2000}, 176 + {Did: "did:plc:third", Handle: "third", SessionId: "s3", AddedAt: 3000}, 177 + }, 178 + } 179 + 180 + t.Run("find existing account", func(t *testing.T) { 181 + found := registry.FindAccount("did:plc:second") 182 + if found == nil { 183 + t.Fatal("FindAccount() returned nil for existing account") 184 + } 185 + if found.Handle != "second" { 186 + t.Errorf("FindAccount() handle = %s, want second", found.Handle) 187 + } 188 + if found.SessionId != "s2" { 189 + t.Errorf("FindAccount() sessionId = %s, want s2", found.SessionId) 190 + } 191 + }) 192 + 193 + t.Run("find non-existing account", func(t *testing.T) { 194 + found := registry.FindAccount("did:plc:notfound") 195 + if found != nil { 196 + t.Errorf("FindAccount() = %v, want nil", found) 197 + } 198 + }) 199 + 200 + t.Run("returned pointer is mutable", func(t *testing.T) { 201 + found := registry.FindAccount("did:plc:first") 202 + if found == nil { 203 + t.Fatal("FindAccount() returned nil") 204 + } 205 + found.SessionId = "modified" 206 + 207 + refetch := registry.FindAccount("did:plc:first") 208 + if refetch.SessionId != "modified" { 209 + t.Errorf("FindAccount() pointer not referencing original, got %s", refetch.SessionId) 210 + } 211 + }) 212 + } 213 + 214 + func TestAccountRegistry_OtherAccounts(t *testing.T) { 215 + registry := &AccountRegistry{ 216 + Accounts: []AccountInfo{ 217 + {Did: "did:plc:active", Handle: "active", SessionId: "s1"}, 218 + {Did: "did:plc:other1", Handle: "other1", SessionId: "s2"}, 219 + {Did: "did:plc:other2", Handle: "other2", SessionId: "s3"}, 220 + }, 221 + } 222 + 223 + others := registry.OtherAccounts("did:plc:active") 224 + 225 + if len(others) != 2 { 226 + t.Errorf("OtherAccounts() len = %d, want 2", len(others)) 227 + } 228 + 229 + for _, acc := range others { 230 + if acc.Did == "did:plc:active" { 231 + t.Errorf("OtherAccounts() should not include active account") 232 + } 233 + } 234 + 235 + hasDid := func(did string) bool { 236 + for _, acc := range others { 237 + if acc.Did == did { 238 + return true 239 + } 240 + } 241 + return false 242 + } 243 + 244 + if !hasDid("did:plc:other1") || !hasDid("did:plc:other2") { 245 + t.Errorf("OtherAccounts() missing expected accounts") 246 + } 247 + } 248 + 249 + func TestMultiAccountUser_Did(t *testing.T) { 250 + t.Run("with active user", func(t *testing.T) { 251 + user := &MultiAccountUser{ 252 + Active: &User{Did: "did:plc:test", Pds: "https://bsky.social"}, 253 + } 254 + if user.Did() != "did:plc:test" { 255 + t.Errorf("Did() = %s, want did:plc:test", user.Did()) 256 + } 257 + }) 258 + 259 + t.Run("with nil active", func(t *testing.T) { 260 + user := &MultiAccountUser{Active: nil} 261 + if user.Did() != "" { 262 + t.Errorf("Did() = %s, want empty string", user.Did()) 263 + } 264 + }) 265 + }
+4
appview/oauth/consts.go
··· 2 3 const ( 4 SessionName = "appview-session-v2" 5 SessionHandle = "handle" 6 SessionDid = "did" 7 SessionId = "id"
··· 2 3 const ( 4 SessionName = "appview-session-v2" 5 + AccountsName = "appview-accounts-v2" 6 + AuthReturnName = "appview-auth-return" 7 + AuthReturnURL = "return_url" 8 + AuthAddAccount = "add_account" 9 SessionHandle = "handle" 10 SessionDid = "did" 11 SessionId = "id"
+14 -2
appview/oauth/handler.go
··· 55 ctx := r.Context() 56 l := o.Logger.With("query", r.URL.Query()) 57 58 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 59 if err != nil { 60 var callbackErr *oauth.AuthRequestCallbackError ··· 70 71 if err := o.SaveSession(w, r, sessData); err != nil { 72 l.Error("failed to save session", "data", sessData, "err", err) 73 - http.Redirect(w, r, "/login?error=session", http.StatusFound) 74 return 75 } 76 ··· 88 } 89 } 90 91 - http.Redirect(w, r, "/", http.StatusFound) 92 } 93 94 func (o *OAuth) addToDefaultSpindle(did string) {
··· 55 ctx := r.Context() 56 l := o.Logger.With("query", r.URL.Query()) 57 58 + authReturn := o.GetAuthReturn(r) 59 + _ = o.ClearAuthReturn(w, r) 60 + 61 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 62 if err != nil { 63 var callbackErr *oauth.AuthRequestCallbackError ··· 73 74 if err := o.SaveSession(w, r, sessData); err != nil { 75 l.Error("failed to save session", "data", sessData, "err", err) 76 + errorCode := "session" 77 + if errors.Is(err, ErrMaxAccountsReached) { 78 + errorCode = "max_accounts" 79 + } 80 + http.Redirect(w, r, fmt.Sprintf("/login?error=%s", errorCode), http.StatusFound) 81 return 82 } 83 ··· 95 } 96 } 97 98 + redirectURL := "/" 99 + if authReturn.ReturnURL != "" { 100 + redirectURL = authReturn.ReturnURL 101 + } 102 + 103 + http.Redirect(w, r, redirectURL, http.StatusFound) 104 } 105 106 func (o *OAuth) addToDefaultSpindle(did string) {
+66 -4
appview/oauth/oauth.go
··· 98 } 99 100 func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 101 - // first we save the did in the user session 102 userSession, err := o.SessStore.Get(r, SessionName) 103 if err != nil { 104 return err ··· 108 userSession.Values[SessionPds] = sessData.HostURL 109 userSession.Values[SessionId] = sessData.SessionID 110 userSession.Values[SessionAuthenticated] = true 111 - return userSession.Save(r, w) 112 } 113 114 func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { ··· 163 return errors.Join(err1, err2) 164 } 165 166 type User struct { 167 Did string 168 Pds string ··· 181 } 182 183 func (o *OAuth) GetDid(r *http.Request) string { 184 - if u := o.GetUser(r); u != nil { 185 - return u.Did 186 } 187 188 return ""
··· 98 } 99 100 func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 101 userSession, err := o.SessStore.Get(r, SessionName) 102 if err != nil { 103 return err ··· 107 userSession.Values[SessionPds] = sessData.HostURL 108 userSession.Values[SessionId] = sessData.SessionID 109 userSession.Values[SessionAuthenticated] = true 110 + 111 + if err := userSession.Save(r, w); err != nil { 112 + return err 113 + } 114 + 115 + handle := "" 116 + resolved, err := o.IdResolver.ResolveIdent(r.Context(), sessData.AccountDID.String()) 117 + if err == nil && resolved.Handle.String() != "" { 118 + handle = resolved.Handle.String() 119 + } 120 + 121 + registry := o.GetAccounts(r) 122 + if err := registry.AddAccount(sessData.AccountDID.String(), handle, sessData.SessionID); err != nil { 123 + return err 124 + } 125 + return o.SaveAccounts(w, r, registry) 126 } 127 128 func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { ··· 177 return errors.Join(err1, err2) 178 } 179 180 + func (o *OAuth) SwitchAccount(w http.ResponseWriter, r *http.Request, targetDid string) error { 181 + registry := o.GetAccounts(r) 182 + account := registry.FindAccount(targetDid) 183 + if account == nil { 184 + return fmt.Errorf("account not found in registry: %s", targetDid) 185 + } 186 + 187 + did, err := syntax.ParseDID(targetDid) 188 + if err != nil { 189 + return fmt.Errorf("invalid DID: %w", err) 190 + } 191 + 192 + sess, err := o.ClientApp.ResumeSession(r.Context(), did, account.SessionId) 193 + if err != nil { 194 + registry.RemoveAccount(targetDid) 195 + _ = o.SaveAccounts(w, r, registry) 196 + return fmt.Errorf("session expired for account: %w", err) 197 + } 198 + 199 + userSession, err := o.SessStore.Get(r, SessionName) 200 + if err != nil { 201 + return err 202 + } 203 + 204 + userSession.Values[SessionDid] = sess.Data.AccountDID.String() 205 + userSession.Values[SessionPds] = sess.Data.HostURL 206 + userSession.Values[SessionId] = sess.Data.SessionID 207 + userSession.Values[SessionAuthenticated] = true 208 + 209 + return userSession.Save(r, w) 210 + } 211 + 212 + func (o *OAuth) RemoveAccount(w http.ResponseWriter, r *http.Request, targetDid string) error { 213 + registry := o.GetAccounts(r) 214 + account := registry.FindAccount(targetDid) 215 + if account == nil { 216 + return nil 217 + } 218 + 219 + did, err := syntax.ParseDID(targetDid) 220 + if err == nil { 221 + _ = o.ClientApp.Logout(r.Context(), did, account.SessionId) 222 + } 223 + 224 + registry.RemoveAccount(targetDid) 225 + return o.SaveAccounts(w, r, registry) 226 + } 227 + 228 type User struct { 229 Did string 230 Pds string ··· 243 } 244 245 func (o *OAuth) GetDid(r *http.Request) string { 246 + if u := o.GetMultiAccountUser(r); u != nil { 247 + return u.Did() 248 } 249 250 return ""
+9 -9
appview/ogcard/card.go
··· 334 return nil 335 } 336 337 - func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error { 338 tpl, err := template.New("dolly"). 339 - ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html") 340 if err != nil { 341 - return fmt.Errorf("failed to read dolly silhouette template: %w", err) 342 } 343 344 var svgData bytes.Buffer 345 - if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil { 346 - return fmt.Errorf("failed to execute dolly silhouette template: %w", err) 347 } 348 349 icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor) ··· 453 454 // Handle SVG separately 455 if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 456 - return c.convertSVGToPNG(bodyBytes) 457 } 458 459 // Support content types are in-sync with the allowed custom avatar file types ··· 493 } 494 495 // convertSVGToPNG converts SVG data to a PNG image 496 - func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) { 497 // Parse the SVG 498 icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 499 if err != nil { ··· 547 draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 548 549 // Draw the image with circular clipping 550 - for cy := 0; cy < size; cy++ { 551 - for cx := 0; cx < size; cx++ { 552 // Calculate distance from center 553 dx := float64(cx - center) 554 dy := float64(cy - center)
··· 334 return nil 335 } 336 337 + func (c *Card) DrawDolly(x, y, size int, iconColor color.Color) error { 338 tpl, err := template.New("dolly"). 339 + ParseFS(pages.Files, "templates/fragments/dolly/logo.html") 340 if err != nil { 341 + return fmt.Errorf("failed to read dolly template: %w", err) 342 } 343 344 var svgData bytes.Buffer 345 + if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", nil); err != nil { 346 + return fmt.Errorf("failed to execute dolly template: %w", err) 347 } 348 349 icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor) ··· 453 454 // Handle SVG separately 455 if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 456 + return convertSVGToPNG(bodyBytes) 457 } 458 459 // Support content types are in-sync with the allowed custom avatar file types ··· 493 } 494 495 // convertSVGToPNG converts SVG data to a PNG image 496 + func convertSVGToPNG(svgData []byte) (image.Image, bool) { 497 // Parse the SVG 498 icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 499 if err != nil { ··· 547 draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 548 549 // Draw the image with circular clipping 550 + for cy := range size { 551 + for cx := range size { 552 // Calculate distance from center 553 dx := float64(cx - center) 554 dy := float64(cy - center)
+30 -9
appview/pages/funcmap.go
··· 26 "github.com/go-enry/go-enry/v2" 27 "github.com/yuin/goldmark" 28 emoji "github.com/yuin/goldmark-emoji" 29 - "tangled.org/core/appview/filetree" 30 "tangled.org/core/appview/models" 31 "tangled.org/core/appview/pages/markup" 32 "tangled.org/core/crypto" 33 ) ··· 334 }, 335 "deref": func(v any) any { 336 val := reflect.ValueOf(v) 337 - if val.Kind() == reflect.Ptr && !val.IsNil() { 338 return val.Elem().Interface() 339 } 340 return nil ··· 348 return template.HTML(data) 349 }, 350 "cssContentHash": p.CssContentHash, 351 - "fileTree": filetree.FileTree, 352 "pathEscape": func(s string) string { 353 return url.PathEscape(s) 354 }, ··· 366 return p.AvatarUrl(handle, "") 367 }, 368 "langColor": enry.GetColor, 369 - "layoutSide": func() string { 370 - return "col-span-1 md:col-span-2 lg:col-span-3" 371 }, 372 - "layoutCenter": func() string { 373 - return "col-span-1 md:col-span-8 lg:col-span-6" 374 - }, 375 - 376 "normalizeForHtmlId": func(s string) string { 377 normalized := strings.ReplaceAll(s, ":", "_") 378 normalized = strings.ReplaceAll(normalized, ".", "_") ··· 384 return "error" 385 } 386 return fp 387 }, 388 } 389 }
··· 26 "github.com/go-enry/go-enry/v2" 27 "github.com/yuin/goldmark" 28 emoji "github.com/yuin/goldmark-emoji" 29 "tangled.org/core/appview/models" 30 + "tangled.org/core/appview/oauth" 31 "tangled.org/core/appview/pages/markup" 32 "tangled.org/core/crypto" 33 ) ··· 334 }, 335 "deref": func(v any) any { 336 val := reflect.ValueOf(v) 337 + if val.Kind() == reflect.Pointer && !val.IsNil() { 338 return val.Elem().Interface() 339 } 340 return nil ··· 348 return template.HTML(data) 349 }, 350 "cssContentHash": p.CssContentHash, 351 "pathEscape": func(s string) string { 352 return url.PathEscape(s) 353 }, ··· 365 return p.AvatarUrl(handle, "") 366 }, 367 "langColor": enry.GetColor, 368 + "reverse": func(s any) any { 369 + if s == nil { 370 + return nil 371 + } 372 + 373 + v := reflect.ValueOf(s) 374 + 375 + if v.Kind() != reflect.Slice { 376 + return s 377 + } 378 + 379 + length := v.Len() 380 + reversed := reflect.MakeSlice(v.Type(), length, length) 381 + 382 + for i := range length { 383 + reversed.Index(i).Set(v.Index(length - 1 - i)) 384 + } 385 + 386 + return reversed.Interface() 387 }, 388 "normalizeForHtmlId": func(s string) string { 389 normalized := strings.ReplaceAll(s, ":", "_") 390 normalized = strings.ReplaceAll(normalized, ".", "_") ··· 396 return "error" 397 } 398 return fp 399 + }, 400 + "otherAccounts": func(activeDid string, accounts []oauth.AccountInfo) []oauth.AccountInfo { 401 + result := make([]oauth.AccountInfo, 0, len(accounts)) 402 + for _, acc := range accounts { 403 + if acc.Did != activeDid { 404 + result = append(result, acc) 405 + } 406 + } 407 + return result 408 }, 409 } 410 }
+13 -3
appview/pages/markup/extension/atlink.go
··· 35 return KindAt 36 } 37 38 - var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`) 39 40 type atParser struct{} 41 ··· 55 if m == nil { 56 return nil 57 } 58 atSegment := text.NewSegment(segment.Start, segment.Start+m[1]) 59 block.Advance(m[1]) 60 node := &AtNode{} ··· 87 88 func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 89 if entering { 90 - w.WriteString(`<a href="/@`) 91 w.WriteString(n.(*AtNode).Handle) 92 - w.WriteString(`" class="mention font-bold">`) 93 } else { 94 w.WriteString("</a>") 95 }
··· 35 return KindAt 36 } 37 38 + var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\b)`) 39 + var markdownLinkRegexp = regexp.MustCompile(`(?ms)\[.*\]\(.*\)`) 40 41 type atParser struct{} 42 ··· 56 if m == nil { 57 return nil 58 } 59 + 60 + // Check for all links in the markdown to see if the handle found is inside one 61 + linksIndexes := markdownLinkRegexp.FindAllIndex(block.Source(), -1) 62 + for _, linkMatch := range linksIndexes { 63 + if linkMatch[0] < segment.Start && segment.Start < linkMatch[1] { 64 + return nil 65 + } 66 + } 67 + 68 atSegment := text.NewSegment(segment.Start, segment.Start+m[1]) 69 block.Advance(m[1]) 70 node := &AtNode{} ··· 97 98 func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 99 if entering { 100 + w.WriteString(`<a href="/`) 101 w.WriteString(n.(*AtNode).Handle) 102 + w.WriteString(`" class="mention">`) 103 } else { 104 w.WriteString("</a>") 105 }
+121
appview/pages/markup/markdown_test.go
···
··· 1 + package markup 2 + 3 + import ( 4 + "bytes" 5 + "testing" 6 + ) 7 + 8 + func TestAtExtension_Rendering(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + markdown string 12 + expected string 13 + }{ 14 + { 15 + name: "renders simple at mention", 16 + markdown: "Hello @user.tngl.sh!", 17 + expected: `<p>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>!</p>`, 18 + }, 19 + { 20 + name: "renders multiple at mentions", 21 + markdown: "Hi @alice.tngl.sh and @bob.example.com", 22 + expected: `<p>Hi <a href="/alice.tngl.sh" class="mention">@alice.tngl.sh</a> and <a href="/bob.example.com" class="mention">@bob.example.com</a></p>`, 23 + }, 24 + { 25 + name: "renders at mention in parentheses", 26 + markdown: "Check this out (@user.tngl.sh)", 27 + expected: `<p>Check this out (<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>)</p>`, 28 + }, 29 + { 30 + name: "does not render email", 31 + markdown: "Contact me at test@example.com", 32 + expected: `<p>Contact me at <a href="mailto:test@example.com">test@example.com</a></p>`, 33 + }, 34 + { 35 + name: "renders at mention with hyphen", 36 + markdown: "Follow @user-name.tngl.sh", 37 + expected: `<p>Follow <a href="/user-name.tngl.sh" class="mention">@user-name.tngl.sh</a></p>`, 38 + }, 39 + { 40 + name: "renders at mention with numbers", 41 + markdown: "@user123.test456.social", 42 + expected: `<p><a href="/user123.test456.social" class="mention">@user123.test456.social</a></p>`, 43 + }, 44 + { 45 + name: "at mention at start of line", 46 + markdown: "@user.tngl.sh is cool", 47 + expected: `<p><a href="/user.tngl.sh" class="mention">@user.tngl.sh</a> is cool</p>`, 48 + }, 49 + } 50 + 51 + for _, tt := range tests { 52 + t.Run(tt.name, func(t *testing.T) { 53 + md := NewMarkdown() 54 + 55 + var buf bytes.Buffer 56 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 57 + t.Fatalf("failed to convert markdown: %v", err) 58 + } 59 + 60 + result := buf.String() 61 + if result != tt.expected+"\n" { 62 + t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, result) 63 + } 64 + }) 65 + } 66 + } 67 + 68 + func TestAtExtension_WithOtherMarkdown(t *testing.T) { 69 + tests := []struct { 70 + name string 71 + markdown string 72 + contains string 73 + }{ 74 + { 75 + name: "at mention with bold", 76 + markdown: "**Hello @user.tngl.sh**", 77 + contains: `<strong>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></strong>`, 78 + }, 79 + { 80 + name: "at mention with italic", 81 + markdown: "*Check @user.tngl.sh*", 82 + contains: `<em>Check <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></em>`, 83 + }, 84 + { 85 + name: "at mention in list", 86 + markdown: "- Item 1\n- @user.tngl.sh\n- Item 3", 87 + contains: `<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>`, 88 + }, 89 + { 90 + name: "at mention in link", 91 + markdown: "[@regnault.dev](https://regnault.dev)", 92 + contains: `<a href="https://regnault.dev">@regnault.dev</a>`, 93 + }, 94 + { 95 + name: "at mention in link again", 96 + markdown: "[check out @regnault.dev](https://regnault.dev)", 97 + contains: `<a href="https://regnault.dev">check out @regnault.dev</a>`, 98 + }, 99 + { 100 + name: "at mention in link again, multiline", 101 + markdown: "[\ncheck out @regnault.dev](https://regnault.dev)", 102 + contains: "<a href=\"https://regnault.dev\">\ncheck out @regnault.dev</a>", 103 + }, 104 + } 105 + 106 + for _, tt := range tests { 107 + t.Run(tt.name, func(t *testing.T) { 108 + md := NewMarkdown() 109 + 110 + var buf bytes.Buffer 111 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 112 + t.Fatalf("failed to convert markdown: %v", err) 113 + } 114 + 115 + result := buf.String() 116 + if !bytes.Contains([]byte(result), []byte(tt.contains)) { 117 + t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result) 118 + } 119 + }) 120 + } 121 + }
+93 -71
appview/pages/pages.go
··· 210 return tpl.ExecuteTemplate(w, "layouts/base", params) 211 } 212 213 func (p *Pages) Favicon(w io.Writer) error { 214 - return p.executePlain("fragments/dolly/silhouette", w, nil) 215 } 216 217 type LoginParams struct { 218 - ReturnUrl string 219 - ErrorCode string 220 } 221 222 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 236 } 237 238 type TermsOfServiceParams struct { 239 - LoggedInUser *oauth.User 240 Content template.HTML 241 } 242 ··· 264 } 265 266 type PrivacyPolicyParams struct { 267 - LoggedInUser *oauth.User 268 Content template.HTML 269 } 270 ··· 292 } 293 294 type BrandParams struct { 295 - LoggedInUser *oauth.User 296 } 297 298 func (p *Pages) Brand(w io.Writer, params BrandParams) error { ··· 300 } 301 302 type TimelineParams struct { 303 - LoggedInUser *oauth.User 304 Timeline []models.TimelineEvent 305 Repos []models.Repo 306 GfiLabel *models.LabelDefinition ··· 311 } 312 313 type GoodFirstIssuesParams struct { 314 - LoggedInUser *oauth.User 315 Issues []models.Issue 316 RepoGroups []*models.RepoGroup 317 LabelDefs map[string]*models.LabelDefinition ··· 324 } 325 326 type UserProfileSettingsParams struct { 327 - LoggedInUser *oauth.User 328 Tabs []map[string]any 329 Tab string 330 } ··· 334 } 335 336 type NotificationsParams struct { 337 - LoggedInUser *oauth.User 338 Notifications []*models.NotificationWithEntity 339 UnreadCount int 340 Page pagination.Page ··· 362 } 363 364 type UserKeysSettingsParams struct { 365 - LoggedInUser *oauth.User 366 PubKeys []models.PublicKey 367 Tabs []map[string]any 368 Tab string ··· 373 } 374 375 type UserEmailsSettingsParams struct { 376 - LoggedInUser *oauth.User 377 Emails []models.Email 378 Tabs []map[string]any 379 Tab string ··· 384 } 385 386 type UserNotificationSettingsParams struct { 387 - LoggedInUser *oauth.User 388 Preferences *models.NotificationPreferences 389 Tabs []map[string]any 390 Tab string ··· 404 } 405 406 type KnotsParams struct { 407 - LoggedInUser *oauth.User 408 Registrations []models.Registration 409 Tabs []map[string]any 410 Tab string ··· 415 } 416 417 type KnotParams struct { 418 - LoggedInUser *oauth.User 419 Registration *models.Registration 420 Members []string 421 Repos map[string][]models.Repo ··· 437 } 438 439 type SpindlesParams struct { 440 - LoggedInUser *oauth.User 441 Spindles []models.Spindle 442 Tabs []map[string]any 443 Tab string ··· 458 } 459 460 type SpindleDashboardParams struct { 461 - LoggedInUser *oauth.User 462 Spindle models.Spindle 463 Members []string 464 Repos map[string][]models.Repo ··· 471 } 472 473 type NewRepoParams struct { 474 - LoggedInUser *oauth.User 475 Knots []string 476 } 477 ··· 480 } 481 482 type ForkRepoParams struct { 483 - LoggedInUser *oauth.User 484 Knots []string 485 RepoInfo repoinfo.RepoInfo 486 } ··· 518 } 519 520 type ProfileOverviewParams struct { 521 - LoggedInUser *oauth.User 522 Repos []models.Repo 523 CollaboratingRepos []models.Repo 524 ProfileTimeline *models.ProfileTimeline ··· 532 } 533 534 type ProfileReposParams struct { 535 - LoggedInUser *oauth.User 536 Repos []models.Repo 537 Card *ProfileCard 538 Active string ··· 544 } 545 546 type ProfileStarredParams struct { 547 - LoggedInUser *oauth.User 548 Repos []models.Repo 549 Card *ProfileCard 550 Active string ··· 556 } 557 558 type ProfileStringsParams struct { 559 - LoggedInUser *oauth.User 560 Strings []models.String 561 Card *ProfileCard 562 Active string ··· 569 570 type FollowCard struct { 571 UserDid string 572 - LoggedInUser *oauth.User 573 FollowStatus models.FollowStatus 574 FollowersCount int64 575 FollowingCount int64 ··· 577 } 578 579 type ProfileFollowersParams struct { 580 - LoggedInUser *oauth.User 581 Followers []FollowCard 582 Card *ProfileCard 583 Active string ··· 589 } 590 591 type ProfileFollowingParams struct { 592 - LoggedInUser *oauth.User 593 Following []FollowCard 594 Card *ProfileCard 595 Active string ··· 601 } 602 603 type FollowFragmentParams struct { 604 - UserDid string 605 - FollowStatus models.FollowStatus 606 } 607 608 func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 609 - return p.executePlain("user/fragments/follow", w, params) 610 } 611 612 type EditBioParams struct { 613 - LoggedInUser *oauth.User 614 Profile *models.Profile 615 } 616 ··· 619 } 620 621 type EditPinsParams struct { 622 - LoggedInUser *oauth.User 623 Profile *models.Profile 624 AllRepos []PinnedRepo 625 } ··· 637 IsStarred bool 638 SubjectAt syntax.ATURI 639 StarCount int 640 } 641 642 func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 643 - return p.executePlain("fragments/starBtn-oob", w, params) 644 } 645 646 type RepoIndexParams struct { 647 - LoggedInUser *oauth.User 648 RepoInfo repoinfo.RepoInfo 649 Active string 650 TagMap map[string][]string ··· 693 } 694 695 type RepoLogParams struct { 696 - LoggedInUser *oauth.User 697 RepoInfo repoinfo.RepoInfo 698 TagMap map[string][]string 699 Active string ··· 710 } 711 712 type RepoCommitParams struct { 713 - LoggedInUser *oauth.User 714 RepoInfo repoinfo.RepoInfo 715 Active string 716 EmailToDid map[string]string ··· 729 } 730 731 type RepoTreeParams struct { 732 - LoggedInUser *oauth.User 733 RepoInfo repoinfo.RepoInfo 734 Active string 735 BreadCrumbs [][]string ··· 784 } 785 786 type RepoBranchesParams struct { 787 - LoggedInUser *oauth.User 788 RepoInfo repoinfo.RepoInfo 789 Active string 790 types.RepoBranchesResponse ··· 796 } 797 798 type RepoTagsParams struct { 799 - LoggedInUser *oauth.User 800 RepoInfo repoinfo.RepoInfo 801 Active string 802 types.RepoTagsResponse ··· 810 } 811 812 type RepoArtifactParams struct { 813 - LoggedInUser *oauth.User 814 RepoInfo repoinfo.RepoInfo 815 Artifact models.Artifact 816 } ··· 820 } 821 822 type RepoBlobParams struct { 823 - LoggedInUser *oauth.User 824 RepoInfo repoinfo.RepoInfo 825 Active string 826 BreadCrumbs [][]string ··· 844 } 845 846 type RepoSettingsParams struct { 847 - LoggedInUser *oauth.User 848 RepoInfo repoinfo.RepoInfo 849 Collaborators []Collaborator 850 Active string ··· 863 } 864 865 type RepoGeneralSettingsParams struct { 866 - LoggedInUser *oauth.User 867 RepoInfo repoinfo.RepoInfo 868 Labels []models.LabelDefinition 869 DefaultLabels []models.LabelDefinition ··· 881 } 882 883 type RepoAccessSettingsParams struct { 884 - LoggedInUser *oauth.User 885 RepoInfo repoinfo.RepoInfo 886 Active string 887 Tabs []map[string]any ··· 895 } 896 897 type RepoPipelineSettingsParams struct { 898 - LoggedInUser *oauth.User 899 RepoInfo repoinfo.RepoInfo 900 Active string 901 Tabs []map[string]any ··· 911 } 912 913 type RepoIssuesParams struct { 914 - LoggedInUser *oauth.User 915 RepoInfo repoinfo.RepoInfo 916 Active string 917 Issues []models.Issue ··· 928 } 929 930 type RepoSingleIssueParams struct { 931 - LoggedInUser *oauth.User 932 RepoInfo repoinfo.RepoInfo 933 Active string 934 Issue *models.Issue ··· 947 } 948 949 type EditIssueParams struct { 950 - LoggedInUser *oauth.User 951 RepoInfo repoinfo.RepoInfo 952 Issue *models.Issue 953 Action string ··· 971 } 972 973 type RepoNewIssueParams struct { 974 - LoggedInUser *oauth.User 975 RepoInfo repoinfo.RepoInfo 976 Issue *models.Issue // existing issue if any -- passed when editing 977 Active string ··· 985 } 986 987 type EditIssueCommentParams struct { 988 - LoggedInUser *oauth.User 989 RepoInfo repoinfo.RepoInfo 990 Issue *models.Issue 991 Comment *models.IssueComment ··· 996 } 997 998 type ReplyIssueCommentPlaceholderParams struct { 999 - LoggedInUser *oauth.User 1000 RepoInfo repoinfo.RepoInfo 1001 Issue *models.Issue 1002 Comment *models.IssueComment ··· 1007 } 1008 1009 type ReplyIssueCommentParams struct { 1010 - LoggedInUser *oauth.User 1011 RepoInfo repoinfo.RepoInfo 1012 Issue *models.Issue 1013 Comment *models.IssueComment ··· 1018 } 1019 1020 type IssueCommentBodyParams struct { 1021 - LoggedInUser *oauth.User 1022 RepoInfo repoinfo.RepoInfo 1023 Issue *models.Issue 1024 Comment *models.IssueComment ··· 1029 } 1030 1031 type RepoNewPullParams struct { 1032 - LoggedInUser *oauth.User 1033 RepoInfo repoinfo.RepoInfo 1034 Branches []types.Branch 1035 Strategy string ··· 1046 } 1047 1048 type RepoPullsParams struct { 1049 - LoggedInUser *oauth.User 1050 RepoInfo repoinfo.RepoInfo 1051 Pulls []*models.Pull 1052 Active string ··· 1055 Stacks map[string]models.Stack 1056 Pipelines map[string]models.Pipeline 1057 LabelDefs map[string]*models.LabelDefinition 1058 } 1059 1060 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1081 } 1082 1083 type RepoSinglePullParams struct { 1084 - LoggedInUser *oauth.User 1085 RepoInfo repoinfo.RepoInfo 1086 Active string 1087 Pull *models.Pull ··· 1092 MergeCheck types.MergeCheckResponse 1093 ResubmitCheck ResubmitResult 1094 Pipelines map[string]models.Pipeline 1095 1096 OrderedReactionKinds []models.ReactionKind 1097 Reactions map[models.ReactionKind]models.ReactionDisplayData ··· 1106 } 1107 1108 type RepoPullPatchParams struct { 1109 - LoggedInUser *oauth.User 1110 RepoInfo repoinfo.RepoInfo 1111 Pull *models.Pull 1112 Stack models.Stack ··· 1123 } 1124 1125 type RepoPullInterdiffParams struct { 1126 - LoggedInUser *oauth.User 1127 RepoInfo repoinfo.RepoInfo 1128 Pull *models.Pull 1129 Round int ··· 1176 } 1177 1178 type PullResubmitParams struct { 1179 - LoggedInUser *oauth.User 1180 RepoInfo repoinfo.RepoInfo 1181 Pull *models.Pull 1182 SubmissionId int ··· 1187 } 1188 1189 type PullActionsParams struct { 1190 - LoggedInUser *oauth.User 1191 RepoInfo repoinfo.RepoInfo 1192 Pull *models.Pull 1193 RoundNumber int ··· 1202 } 1203 1204 type PullNewCommentParams struct { 1205 - LoggedInUser *oauth.User 1206 RepoInfo repoinfo.RepoInfo 1207 Pull *models.Pull 1208 RoundNumber int ··· 1213 } 1214 1215 type RepoCompareParams struct { 1216 - LoggedInUser *oauth.User 1217 RepoInfo repoinfo.RepoInfo 1218 Forks []models.Repo 1219 Branches []types.Branch ··· 1232 } 1233 1234 type RepoCompareNewParams struct { 1235 - LoggedInUser *oauth.User 1236 RepoInfo repoinfo.RepoInfo 1237 Forks []models.Repo 1238 Branches []types.Branch ··· 1249 } 1250 1251 type RepoCompareAllowPullParams struct { 1252 - LoggedInUser *oauth.User 1253 RepoInfo repoinfo.RepoInfo 1254 Base string 1255 Head string ··· 1269 } 1270 1271 type LabelPanelParams struct { 1272 - LoggedInUser *oauth.User 1273 RepoInfo repoinfo.RepoInfo 1274 Defs map[string]*models.LabelDefinition 1275 Subject string ··· 1281 } 1282 1283 type EditLabelPanelParams struct { 1284 - LoggedInUser *oauth.User 1285 RepoInfo repoinfo.RepoInfo 1286 Defs map[string]*models.LabelDefinition 1287 Subject string ··· 1293 } 1294 1295 type PipelinesParams struct { 1296 - LoggedInUser *oauth.User 1297 RepoInfo repoinfo.RepoInfo 1298 Pipelines []models.Pipeline 1299 Active string ··· 1336 } 1337 1338 type WorkflowParams struct { 1339 - LoggedInUser *oauth.User 1340 RepoInfo repoinfo.RepoInfo 1341 Pipeline models.Pipeline 1342 Workflow string ··· 1350 } 1351 1352 type PutStringParams struct { 1353 - LoggedInUser *oauth.User 1354 Action string 1355 1356 // this is supplied in the case of editing an existing string ··· 1362 } 1363 1364 type StringsDashboardParams struct { 1365 - LoggedInUser *oauth.User 1366 Card ProfileCard 1367 Strings []models.String 1368 } ··· 1372 } 1373 1374 type StringTimelineParams struct { 1375 - LoggedInUser *oauth.User 1376 Strings []models.String 1377 } 1378 ··· 1381 } 1382 1383 type SingleStringParams struct { 1384 - LoggedInUser *oauth.User 1385 ShowRendered bool 1386 RenderToggle bool 1387 RenderedContents template.HTML
··· 210 return tpl.ExecuteTemplate(w, "layouts/base", params) 211 } 212 213 + type DollyParams struct { 214 + Classes string 215 + FillColor string 216 + } 217 + 218 + func (p *Pages) Dolly(w io.Writer, params DollyParams) error { 219 + return p.executePlain("fragments/dolly/logo", w, params) 220 + } 221 + 222 func (p *Pages) Favicon(w io.Writer) error { 223 + return p.Dolly(w, DollyParams{ 224 + Classes: "text-black dark:text-white", 225 + }) 226 } 227 228 type LoginParams struct { 229 + ReturnUrl string 230 + ErrorCode string 231 + AddAccount bool 232 + LoggedInUser *oauth.MultiAccountUser 233 } 234 235 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 249 } 250 251 type TermsOfServiceParams struct { 252 + LoggedInUser *oauth.MultiAccountUser 253 Content template.HTML 254 } 255 ··· 277 } 278 279 type PrivacyPolicyParams struct { 280 + LoggedInUser *oauth.MultiAccountUser 281 Content template.HTML 282 } 283 ··· 305 } 306 307 type BrandParams struct { 308 + LoggedInUser *oauth.MultiAccountUser 309 } 310 311 func (p *Pages) Brand(w io.Writer, params BrandParams) error { ··· 313 } 314 315 type TimelineParams struct { 316 + LoggedInUser *oauth.MultiAccountUser 317 Timeline []models.TimelineEvent 318 Repos []models.Repo 319 GfiLabel *models.LabelDefinition ··· 324 } 325 326 type GoodFirstIssuesParams struct { 327 + LoggedInUser *oauth.MultiAccountUser 328 Issues []models.Issue 329 RepoGroups []*models.RepoGroup 330 LabelDefs map[string]*models.LabelDefinition ··· 337 } 338 339 type UserProfileSettingsParams struct { 340 + LoggedInUser *oauth.MultiAccountUser 341 Tabs []map[string]any 342 Tab string 343 } ··· 347 } 348 349 type NotificationsParams struct { 350 + LoggedInUser *oauth.MultiAccountUser 351 Notifications []*models.NotificationWithEntity 352 UnreadCount int 353 Page pagination.Page ··· 375 } 376 377 type UserKeysSettingsParams struct { 378 + LoggedInUser *oauth.MultiAccountUser 379 PubKeys []models.PublicKey 380 Tabs []map[string]any 381 Tab string ··· 386 } 387 388 type UserEmailsSettingsParams struct { 389 + LoggedInUser *oauth.MultiAccountUser 390 Emails []models.Email 391 Tabs []map[string]any 392 Tab string ··· 397 } 398 399 type UserNotificationSettingsParams struct { 400 + LoggedInUser *oauth.MultiAccountUser 401 Preferences *models.NotificationPreferences 402 Tabs []map[string]any 403 Tab string ··· 417 } 418 419 type KnotsParams struct { 420 + LoggedInUser *oauth.MultiAccountUser 421 Registrations []models.Registration 422 Tabs []map[string]any 423 Tab string ··· 428 } 429 430 type KnotParams struct { 431 + LoggedInUser *oauth.MultiAccountUser 432 Registration *models.Registration 433 Members []string 434 Repos map[string][]models.Repo ··· 450 } 451 452 type SpindlesParams struct { 453 + LoggedInUser *oauth.MultiAccountUser 454 Spindles []models.Spindle 455 Tabs []map[string]any 456 Tab string ··· 471 } 472 473 type SpindleDashboardParams struct { 474 + LoggedInUser *oauth.MultiAccountUser 475 Spindle models.Spindle 476 Members []string 477 Repos map[string][]models.Repo ··· 484 } 485 486 type NewRepoParams struct { 487 + LoggedInUser *oauth.MultiAccountUser 488 Knots []string 489 } 490 ··· 493 } 494 495 type ForkRepoParams struct { 496 + LoggedInUser *oauth.MultiAccountUser 497 Knots []string 498 RepoInfo repoinfo.RepoInfo 499 } ··· 531 } 532 533 type ProfileOverviewParams struct { 534 + LoggedInUser *oauth.MultiAccountUser 535 Repos []models.Repo 536 CollaboratingRepos []models.Repo 537 ProfileTimeline *models.ProfileTimeline ··· 545 } 546 547 type ProfileReposParams struct { 548 + LoggedInUser *oauth.MultiAccountUser 549 Repos []models.Repo 550 Card *ProfileCard 551 Active string ··· 557 } 558 559 type ProfileStarredParams struct { 560 + LoggedInUser *oauth.MultiAccountUser 561 Repos []models.Repo 562 Card *ProfileCard 563 Active string ··· 569 } 570 571 type ProfileStringsParams struct { 572 + LoggedInUser *oauth.MultiAccountUser 573 Strings []models.String 574 Card *ProfileCard 575 Active string ··· 582 583 type FollowCard struct { 584 UserDid string 585 + LoggedInUser *oauth.MultiAccountUser 586 FollowStatus models.FollowStatus 587 FollowersCount int64 588 FollowingCount int64 ··· 590 } 591 592 type ProfileFollowersParams struct { 593 + LoggedInUser *oauth.MultiAccountUser 594 Followers []FollowCard 595 Card *ProfileCard 596 Active string ··· 602 } 603 604 type ProfileFollowingParams struct { 605 + LoggedInUser *oauth.MultiAccountUser 606 Following []FollowCard 607 Card *ProfileCard 608 Active string ··· 614 } 615 616 type FollowFragmentParams struct { 617 + UserDid string 618 + FollowStatus models.FollowStatus 619 + FollowersCount int64 620 } 621 622 func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 623 + return p.executePlain("user/fragments/follow-oob", w, params) 624 } 625 626 type EditBioParams struct { 627 + LoggedInUser *oauth.MultiAccountUser 628 Profile *models.Profile 629 } 630 ··· 633 } 634 635 type EditPinsParams struct { 636 + LoggedInUser *oauth.MultiAccountUser 637 Profile *models.Profile 638 AllRepos []PinnedRepo 639 } ··· 651 IsStarred bool 652 SubjectAt syntax.ATURI 653 StarCount int 654 + HxSwapOob bool 655 } 656 657 func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 658 + params.HxSwapOob = true 659 + return p.executePlain("fragments/starBtn", w, params) 660 } 661 662 type RepoIndexParams struct { 663 + LoggedInUser *oauth.MultiAccountUser 664 RepoInfo repoinfo.RepoInfo 665 Active string 666 TagMap map[string][]string ··· 709 } 710 711 type RepoLogParams struct { 712 + LoggedInUser *oauth.MultiAccountUser 713 RepoInfo repoinfo.RepoInfo 714 TagMap map[string][]string 715 Active string ··· 726 } 727 728 type RepoCommitParams struct { 729 + LoggedInUser *oauth.MultiAccountUser 730 RepoInfo repoinfo.RepoInfo 731 Active string 732 EmailToDid map[string]string ··· 745 } 746 747 type RepoTreeParams struct { 748 + LoggedInUser *oauth.MultiAccountUser 749 RepoInfo repoinfo.RepoInfo 750 Active string 751 BreadCrumbs [][]string ··· 800 } 801 802 type RepoBranchesParams struct { 803 + LoggedInUser *oauth.MultiAccountUser 804 RepoInfo repoinfo.RepoInfo 805 Active string 806 types.RepoBranchesResponse ··· 812 } 813 814 type RepoTagsParams struct { 815 + LoggedInUser *oauth.MultiAccountUser 816 RepoInfo repoinfo.RepoInfo 817 Active string 818 types.RepoTagsResponse ··· 826 } 827 828 type RepoArtifactParams struct { 829 + LoggedInUser *oauth.MultiAccountUser 830 RepoInfo repoinfo.RepoInfo 831 Artifact models.Artifact 832 } ··· 836 } 837 838 type RepoBlobParams struct { 839 + LoggedInUser *oauth.MultiAccountUser 840 RepoInfo repoinfo.RepoInfo 841 Active string 842 BreadCrumbs [][]string ··· 860 } 861 862 type RepoSettingsParams struct { 863 + LoggedInUser *oauth.MultiAccountUser 864 RepoInfo repoinfo.RepoInfo 865 Collaborators []Collaborator 866 Active string ··· 879 } 880 881 type RepoGeneralSettingsParams struct { 882 + LoggedInUser *oauth.MultiAccountUser 883 RepoInfo repoinfo.RepoInfo 884 Labels []models.LabelDefinition 885 DefaultLabels []models.LabelDefinition ··· 897 } 898 899 type RepoAccessSettingsParams struct { 900 + LoggedInUser *oauth.MultiAccountUser 901 RepoInfo repoinfo.RepoInfo 902 Active string 903 Tabs []map[string]any ··· 911 } 912 913 type RepoPipelineSettingsParams struct { 914 + LoggedInUser *oauth.MultiAccountUser 915 RepoInfo repoinfo.RepoInfo 916 Active string 917 Tabs []map[string]any ··· 927 } 928 929 type RepoIssuesParams struct { 930 + LoggedInUser *oauth.MultiAccountUser 931 RepoInfo repoinfo.RepoInfo 932 Active string 933 Issues []models.Issue ··· 944 } 945 946 type RepoSingleIssueParams struct { 947 + LoggedInUser *oauth.MultiAccountUser 948 RepoInfo repoinfo.RepoInfo 949 Active string 950 Issue *models.Issue ··· 963 } 964 965 type EditIssueParams struct { 966 + LoggedInUser *oauth.MultiAccountUser 967 RepoInfo repoinfo.RepoInfo 968 Issue *models.Issue 969 Action string ··· 987 } 988 989 type RepoNewIssueParams struct { 990 + LoggedInUser *oauth.MultiAccountUser 991 RepoInfo repoinfo.RepoInfo 992 Issue *models.Issue // existing issue if any -- passed when editing 993 Active string ··· 1001 } 1002 1003 type EditIssueCommentParams struct { 1004 + LoggedInUser *oauth.MultiAccountUser 1005 RepoInfo repoinfo.RepoInfo 1006 Issue *models.Issue 1007 Comment *models.IssueComment ··· 1012 } 1013 1014 type ReplyIssueCommentPlaceholderParams struct { 1015 + LoggedInUser *oauth.MultiAccountUser 1016 RepoInfo repoinfo.RepoInfo 1017 Issue *models.Issue 1018 Comment *models.IssueComment ··· 1023 } 1024 1025 type ReplyIssueCommentParams struct { 1026 + LoggedInUser *oauth.MultiAccountUser 1027 RepoInfo repoinfo.RepoInfo 1028 Issue *models.Issue 1029 Comment *models.IssueComment ··· 1034 } 1035 1036 type IssueCommentBodyParams struct { 1037 + LoggedInUser *oauth.MultiAccountUser 1038 RepoInfo repoinfo.RepoInfo 1039 Issue *models.Issue 1040 Comment *models.IssueComment ··· 1045 } 1046 1047 type RepoNewPullParams struct { 1048 + LoggedInUser *oauth.MultiAccountUser 1049 RepoInfo repoinfo.RepoInfo 1050 Branches []types.Branch 1051 Strategy string ··· 1062 } 1063 1064 type RepoPullsParams struct { 1065 + LoggedInUser *oauth.MultiAccountUser 1066 RepoInfo repoinfo.RepoInfo 1067 Pulls []*models.Pull 1068 Active string ··· 1071 Stacks map[string]models.Stack 1072 Pipelines map[string]models.Pipeline 1073 LabelDefs map[string]*models.LabelDefinition 1074 + Page pagination.Page 1075 + PullCount int 1076 } 1077 1078 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1099 } 1100 1101 type RepoSinglePullParams struct { 1102 + LoggedInUser *oauth.MultiAccountUser 1103 RepoInfo repoinfo.RepoInfo 1104 Active string 1105 Pull *models.Pull ··· 1110 MergeCheck types.MergeCheckResponse 1111 ResubmitCheck ResubmitResult 1112 Pipelines map[string]models.Pipeline 1113 + Diff types.DiffRenderer 1114 + DiffOpts types.DiffOpts 1115 + ActiveRound int 1116 + IsInterdiff bool 1117 1118 OrderedReactionKinds []models.ReactionKind 1119 Reactions map[models.ReactionKind]models.ReactionDisplayData ··· 1128 } 1129 1130 type RepoPullPatchParams struct { 1131 + LoggedInUser *oauth.MultiAccountUser 1132 RepoInfo repoinfo.RepoInfo 1133 Pull *models.Pull 1134 Stack models.Stack ··· 1145 } 1146 1147 type RepoPullInterdiffParams struct { 1148 + LoggedInUser *oauth.MultiAccountUser 1149 RepoInfo repoinfo.RepoInfo 1150 Pull *models.Pull 1151 Round int ··· 1198 } 1199 1200 type PullResubmitParams struct { 1201 + LoggedInUser *oauth.MultiAccountUser 1202 RepoInfo repoinfo.RepoInfo 1203 Pull *models.Pull 1204 SubmissionId int ··· 1209 } 1210 1211 type PullActionsParams struct { 1212 + LoggedInUser *oauth.MultiAccountUser 1213 RepoInfo repoinfo.RepoInfo 1214 Pull *models.Pull 1215 RoundNumber int ··· 1224 } 1225 1226 type PullNewCommentParams struct { 1227 + LoggedInUser *oauth.MultiAccountUser 1228 RepoInfo repoinfo.RepoInfo 1229 Pull *models.Pull 1230 RoundNumber int ··· 1235 } 1236 1237 type RepoCompareParams struct { 1238 + LoggedInUser *oauth.MultiAccountUser 1239 RepoInfo repoinfo.RepoInfo 1240 Forks []models.Repo 1241 Branches []types.Branch ··· 1254 } 1255 1256 type RepoCompareNewParams struct { 1257 + LoggedInUser *oauth.MultiAccountUser 1258 RepoInfo repoinfo.RepoInfo 1259 Forks []models.Repo 1260 Branches []types.Branch ··· 1271 } 1272 1273 type RepoCompareAllowPullParams struct { 1274 + LoggedInUser *oauth.MultiAccountUser 1275 RepoInfo repoinfo.RepoInfo 1276 Base string 1277 Head string ··· 1291 } 1292 1293 type LabelPanelParams struct { 1294 + LoggedInUser *oauth.MultiAccountUser 1295 RepoInfo repoinfo.RepoInfo 1296 Defs map[string]*models.LabelDefinition 1297 Subject string ··· 1303 } 1304 1305 type EditLabelPanelParams struct { 1306 + LoggedInUser *oauth.MultiAccountUser 1307 RepoInfo repoinfo.RepoInfo 1308 Defs map[string]*models.LabelDefinition 1309 Subject string ··· 1315 } 1316 1317 type PipelinesParams struct { 1318 + LoggedInUser *oauth.MultiAccountUser 1319 RepoInfo repoinfo.RepoInfo 1320 Pipelines []models.Pipeline 1321 Active string ··· 1358 } 1359 1360 type WorkflowParams struct { 1361 + LoggedInUser *oauth.MultiAccountUser 1362 RepoInfo repoinfo.RepoInfo 1363 Pipeline models.Pipeline 1364 Workflow string ··· 1372 } 1373 1374 type PutStringParams struct { 1375 + LoggedInUser *oauth.MultiAccountUser 1376 Action string 1377 1378 // this is supplied in the case of editing an existing string ··· 1384 } 1385 1386 type StringsDashboardParams struct { 1387 + LoggedInUser *oauth.MultiAccountUser 1388 Card ProfileCard 1389 Strings []models.String 1390 } ··· 1394 } 1395 1396 type StringTimelineParams struct { 1397 + LoggedInUser *oauth.MultiAccountUser 1398 Strings []models.String 1399 } 1400 ··· 1403 } 1404 1405 type SingleStringParams struct { 1406 + LoggedInUser *oauth.MultiAccountUser 1407 ShowRendered bool 1408 RenderToggle bool 1409 RenderedContents template.HTML
+1 -1
appview/pages/templates/banner.html
··· 30 <div class="mx-6"> 31 These services may not be fully accessible until upgraded. 32 <a class="underline text-red-800 dark:text-red-200" 33 - href="https://tangled.org/@tangled.org/core/tree/master/docs/migrations.md"> 34 Click to read the upgrade guide</a>. 35 </div> 36 </details>
··· 30 <div class="mx-6"> 31 These services may not be fully accessible until upgraded. 32 <a class="underline text-red-800 dark:text-red-200" 33 + href="https://docs.tangled.org/migrating-knots-spindles.html#migrating-knots-spindles"> 34 Click to read the upgrade guide</a>. 35 </div> 36 </details>
+9 -29
appview/pages/templates/brand/brand.html
··· 4 <div class="grid grid-cols-10"> 5 <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 <h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1> 7 - <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 Assets and guidelines for using Tangled's logo and brand elements. 9 </p> 10 </header> ··· 14 15 <!-- Introduction Section --> 16 <section> 17 - <p class="text-gray-600 dark:text-gray-400 mb-2"> 18 Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please 19 follow the below guidelines when using Dolly and the logotype. 20 </p> 21 - <p class="text-gray-600 dark:text-gray-400 mb-2"> 22 All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as". 23 </p> 24 </section> ··· 34 </div> 35 <div class="order-1 lg:order-2"> 36 <h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2> 37 - <p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p> 38 <p class="text-gray-700 dark:text-gray-300"> 39 This is the preferred version of the logotype, featuring dark text and elements, ideal for light 40 backgrounds and designs. ··· 53 </div> 54 <div class="order-1 lg:order-2"> 55 <h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2> 56 - <p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p> 57 <p class="text-gray-700 dark:text-gray-300"> 58 This version features white text and elements, ideal for dark backgrounds 59 and inverted designs. ··· 81 </div> 82 <div class="order-1 lg:order-2"> 83 <h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2> 84 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 85 When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own. 86 </p> 87 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 123 </div> 124 <div class="order-1 lg:order-2"> 125 <h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2> 126 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 127 White logo mark on colored backgrounds. 128 </p> 129 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 165 </div> 166 <div class="order-1 lg:order-2"> 167 <h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2> 168 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 169 Dark logo mark on lighter, pastel backgrounds. 170 </p> 171 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 186 </div> 187 <div class="order-1 lg:order-2"> 188 <h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2> 189 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 190 Custom coloring of the logotype is permitted. 191 </p> 192 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 194 </p> 195 <p class="text-gray-700 dark:text-gray-300 text-sm"> 196 <strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background. 197 - </p> 198 - </div> 199 - </section> 200 - 201 - <!-- Silhouette Section --> 202 - <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 203 - <div class="order-2 lg:order-1"> 204 - <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 205 - <img src="https://assets.tangled.network/tangled_dolly_silhouette.svg" 206 - alt="Dolly silhouette" 207 - class="w-full max-w-32 mx-auto" /> 208 - </div> 209 - </div> 210 - <div class="order-1 lg:order-2"> 211 - <h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2> 212 - <p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p> 213 - <p class="text-gray-700 dark:text-gray-300"> 214 - The silhouette can be used where a subtle brand presence is needed, 215 - or as a background element. Works on any background color with proper contrast. 216 - For example, we use this as the site's favicon. 217 </p> 218 </div> 219 </section>
··· 4 <div class="grid grid-cols-10"> 5 <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 <h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1> 7 + <p class="text-gray-500 dark:text-gray-300 mb-1"> 8 Assets and guidelines for using Tangled's logo and brand elements. 9 </p> 10 </header> ··· 14 15 <!-- Introduction Section --> 16 <section> 17 + <p class="text-gray-500 dark:text-gray-300 mb-2"> 18 Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please 19 follow the below guidelines when using Dolly and the logotype. 20 </p> 21 + <p class="text-gray-500 dark:text-gray-300 mb-2"> 22 All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as". 23 </p> 24 </section> ··· 34 </div> 35 <div class="order-1 lg:order-2"> 36 <h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2> 37 + <p class="text-gray-500 dark:text-gray-300 mb-4">For use on light-colored backgrounds.</p> 38 <p class="text-gray-700 dark:text-gray-300"> 39 This is the preferred version of the logotype, featuring dark text and elements, ideal for light 40 backgrounds and designs. ··· 53 </div> 54 <div class="order-1 lg:order-2"> 55 <h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2> 56 + <p class="text-gray-500 dark:text-gray-300 mb-4">For use on dark-colored backgrounds.</p> 57 <p class="text-gray-700 dark:text-gray-300"> 58 This version features white text and elements, ideal for dark backgrounds 59 and inverted designs. ··· 81 </div> 82 <div class="order-1 lg:order-2"> 83 <h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2> 84 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 85 When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own. 86 </p> 87 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 123 </div> 124 <div class="order-1 lg:order-2"> 125 <h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2> 126 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 127 White logo mark on colored backgrounds. 128 </p> 129 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 165 </div> 166 <div class="order-1 lg:order-2"> 167 <h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2> 168 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 169 Dark logo mark on lighter, pastel backgrounds. 170 </p> 171 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 186 </div> 187 <div class="order-1 lg:order-2"> 188 <h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2> 189 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 190 Custom coloring of the logotype is permitted. 191 </p> 192 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 194 </p> 195 <p class="text-gray-700 dark:text-gray-300 text-sm"> 196 <strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background. 197 </p> 198 </div> 199 </section>
+14 -2
appview/pages/templates/fragments/dolly/logo.html
··· 2 <svg 3 version="1.1" 4 id="svg1" 5 - class="{{ . }}" 6 width="25" 7 height="25" 8 viewBox="0 0 25 25" ··· 17 xmlns:svg="http://www.w3.org/2000/svg" 18 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 19 xmlns:cc="http://creativecommons.org/ns#"> 20 <sodipodi:namedview 21 id="namedview1" 22 pagecolor="#ffffff" ··· 51 id="g1" 52 transform="translate(-0.42924038,-0.87777209)"> 53 <path 54 - fill="currentColor" 55 style="stroke-width:0.111183;" 56 d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z" 57 id="path4"
··· 2 <svg 3 version="1.1" 4 id="svg1" 5 + class="{{ .Classes }}" 6 width="25" 7 height="25" 8 viewBox="0 0 25 25" ··· 17 xmlns:svg="http://www.w3.org/2000/svg" 18 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 19 xmlns:cc="http://creativecommons.org/ns#"> 20 + <style> 21 + .dolly { 22 + color: #000000; 23 + } 24 + 25 + @media (prefers-color-scheme: dark) { 26 + .dolly { 27 + color: #ffffff; 28 + } 29 + } 30 + </style> 31 <sodipodi:namedview 32 id="namedview1" 33 pagecolor="#ffffff" ··· 62 id="g1" 63 transform="translate(-0.42924038,-0.87777209)"> 64 <path 65 + class="dolly" 66 + fill="{{ or .FillColor "currentColor" }}" 67 style="stroke-width:0.111183;" 68 d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z" 69 id="path4"
-95
appview/pages/templates/fragments/dolly/silhouette.html
··· 1 - {{ define "fragments/dolly/silhouette" }} 2 - <svg 3 - version="1.1" 4 - id="svg1" 5 - width="25" 6 - height="25" 7 - viewBox="0 0 25 25" 8 - sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg" 9 - inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg" 10 - inkscape:export-xdpi="96" 11 - inkscape:export-ydpi="96" 12 - inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 13 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 14 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 15 - xmlns="http://www.w3.org/2000/svg" 16 - xmlns:svg="http://www.w3.org/2000/svg" 17 - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 18 - xmlns:cc="http://creativecommons.org/ns#"> 19 - <style> 20 - .dolly { 21 - color: #000000; 22 - } 23 - 24 - @media (prefers-color-scheme: dark) { 25 - .dolly { 26 - color: #ffffff; 27 - } 28 - } 29 - </style> 30 - <sodipodi:namedview 31 - id="namedview1" 32 - pagecolor="#ffffff" 33 - bordercolor="#000000" 34 - borderopacity="0.25" 35 - inkscape:showpageshadow="2" 36 - inkscape:pageopacity="0.0" 37 - inkscape:pagecheckerboard="true" 38 - inkscape:deskcolor="#d5d5d5" 39 - inkscape:zoom="64" 40 - inkscape:cx="4.96875" 41 - inkscape:cy="13.429688" 42 - inkscape:window-width="3840" 43 - inkscape:window-height="2160" 44 - inkscape:window-x="0" 45 - inkscape:window-y="0" 46 - inkscape:window-maximized="0" 47 - inkscape:current-layer="g1" 48 - borderlayer="true"> 49 - <inkscape:page 50 - x="0" 51 - y="0" 52 - width="25" 53 - height="25" 54 - id="page2" 55 - margin="0" 56 - bleed="0" /> 57 - </sodipodi:namedview> 58 - <g 59 - inkscape:groupmode="layer" 60 - inkscape:label="Image" 61 - id="g1" 62 - transform="translate(-0.42924038,-0.87777209)"> 63 - <path 64 - class="dolly" 65 - fill="currentColor" 66 - style="stroke-width:0.111183" 67 - d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z" 68 - id="path7" 69 - sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" /> 70 - </g> 71 - <metadata 72 - id="metadata1"> 73 - <rdf:RDF> 74 - <cc:Work 75 - rdf:about=""> 76 - <cc:license 77 - rdf:resource="http://creativecommons.org/licenses/by/4.0/" /> 78 - </cc:Work> 79 - <cc:License 80 - rdf:about="http://creativecommons.org/licenses/by/4.0/"> 81 - <cc:permits 82 - rdf:resource="http://creativecommons.org/ns#Reproduction" /> 83 - <cc:permits 84 - rdf:resource="http://creativecommons.org/ns#Distribution" /> 85 - <cc:requires 86 - rdf:resource="http://creativecommons.org/ns#Notice" /> 87 - <cc:requires 88 - rdf:resource="http://creativecommons.org/ns#Attribution" /> 89 - <cc:permits 90 - rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /> 91 - </cc:License> 92 - </rdf:RDF> 93 - </metadata> 94 - </svg> 95 - {{ end }}
···
+1 -1
appview/pages/templates/fragments/logotype.html
··· 1 {{ define "fragments/logotype" }} 2 <span class="flex items-center gap-2"> 3 - {{ template "fragments/dolly/logo" "size-16 text-black dark:text-white" }} 4 <span class="font-bold text-4xl not-italic">tangled</span> 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 alpha
··· 1 {{ define "fragments/logotype" }} 2 <span class="flex items-center gap-2"> 3 + {{ template "fragments/dolly/logo" (dict "Classes" "size-16 text-black dark:text-white") }} 4 <span class="font-bold text-4xl not-italic">tangled</span> 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 alpha
+1 -1
appview/pages/templates/fragments/logotypeSmall.html
··· 1 {{ define "fragments/logotypeSmall" }} 2 <span class="flex items-center gap-2"> 3 - {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 4 <span class="font-bold text-xl not-italic">tangled</span> 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 alpha
··· 1 {{ define "fragments/logotypeSmall" }} 2 <span class="flex items-center gap-2"> 3 + {{ template "fragments/dolly/logo" (dict "Classes" "size-8 text-black dark:text-white")}} 4 <span class="font-bold text-xl not-italic">tangled</span> 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 alpha
+95
appview/pages/templates/fragments/pagination.html
···
··· 1 + {{ define "fragments/pagination" }} 2 + {{/* Params: Page (pagination.Page), TotalCount (int), BasePath (string), QueryParams (string) */}} 3 + {{ $page := .Page }} 4 + {{ $totalCount := .TotalCount }} 5 + {{ $basePath := .BasePath }} 6 + {{ $queryParams := .QueryParams }} 7 + 8 + {{ $prev := $page.Previous.Offset }} 9 + {{ $next := $page.Next.Offset }} 10 + {{ $lastPage := sub $totalCount (mod $totalCount $page.Limit) }} 11 + 12 + <div class="flex justify-center items-center mt-4 gap-2"> 13 + <a 14 + class=" 15 + btn flex items-center gap-2 no-underline hover:no-underline 16 + dark:text-white dark:hover:bg-gray-700 17 + {{ if le $page.Offset 0 }} 18 + cursor-not-allowed opacity-50 19 + {{ end }} 20 + " 21 + {{ if gt $page.Offset 0 }} 22 + hx-boost="true" 23 + href="{{ $basePath }}?{{ $queryParams }}&offset={{ $prev }}&limit={{ $page.Limit }}" 24 + {{ end }} 25 + > 26 + {{ i "chevron-left" "w-4 h-4" }} 27 + previous 28 + </a> 29 + 30 + {{ if gt $page.Offset 0 }} 31 + <a 32 + hx-boost="true" 33 + href="{{ $basePath }}?{{ $queryParams }}&offset=0&limit={{ $page.Limit }}" 34 + > 35 + 1 36 + </a> 37 + {{ end }} 38 + 39 + {{ if gt $prev $page.Limit }} 40 + <span>...</span> 41 + {{ end }} 42 + 43 + {{ if gt $prev 0 }} 44 + <a 45 + hx-boost="true" 46 + href="{{ $basePath }}?{{ $queryParams }}&offset={{ $prev }}&limit={{ $page.Limit }}" 47 + > 48 + {{ add (div $prev $page.Limit) 1 }} 49 + </a> 50 + {{ end }} 51 + 52 + <span class="font-bold"> 53 + {{ add (div $page.Offset $page.Limit) 1 }} 54 + </span> 55 + 56 + {{ if lt $next $lastPage }} 57 + <a 58 + hx-boost="true" 59 + href="{{ $basePath }}?{{ $queryParams }}&offset={{ $next }}&limit={{ $page.Limit }}" 60 + > 61 + {{ add (div $next $page.Limit) 1 }} 62 + </a> 63 + {{ end }} 64 + 65 + {{ if lt $next (sub $totalCount (mul 2 $page.Limit)) }} 66 + <span>...</span> 67 + {{ end }} 68 + 69 + {{ if lt $page.Offset $lastPage }} 70 + <a 71 + hx-boost="true" 72 + href="{{ $basePath }}?{{ $queryParams }}&offset={{ $lastPage }}&limit={{ $page.Limit }}" 73 + > 74 + {{ add (div $lastPage $page.Limit) 1 }} 75 + </a> 76 + {{ end }} 77 + 78 + <a 79 + class=" 80 + btn flex items-center gap-2 no-underline hover:no-underline 81 + dark:text-white dark:hover:bg-gray-700 82 + {{ if lt $next $totalCount | not }} 83 + cursor-not-allowed opacity-50 84 + {{ end }} 85 + " 86 + {{ if lt $next $totalCount }} 87 + hx-boost="true" 88 + href="{{ $basePath }}?{{ $queryParams }}&offset={{ $next }}&limit={{ $page.Limit }}" 89 + {{ end }} 90 + > 91 + next 92 + {{ i "chevron-right" "w-4 h-4" }} 93 + </a> 94 + </div> 95 + {{ end }}
-5
appview/pages/templates/fragments/starBtn-oob.html
··· 1 - {{ define "fragments/starBtn-oob" }} 2 - <div hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'> 3 - {{ template "fragments/starBtn" . }} 4 - </div> 5 - {{ end }}
···
+1
appview/pages/templates/fragments/starBtn.html
··· 9 {{ else }} 10 hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}" 11 {{ end }} 12 13 hx-trigger="click" 14 hx-disabled-elt="#starBtn"
··· 9 {{ else }} 10 hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}" 11 {{ end }} 12 + {{ if .HxSwapOob }}hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'{{ end }} 13 14 hx-trigger="click" 15 hx-disabled-elt="#starBtn"
+7 -5
appview/pages/templates/fragments/tinyAvatarList.html
··· 5 <div class="inline-flex items-center -space-x-3"> 6 {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 7 {{ range $i, $p := $ps }} 8 - <img 9 - src="{{ tinyAvatar . }}" 10 - alt="" 11 - class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0 {{ $classes }}" 12 - /> 13 {{ end }} 14 15 {{ if gt (len $all) 5 }}
··· 5 <div class="inline-flex items-center -space-x-3"> 6 {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 7 {{ range $i, $p := $ps }} 8 + <a href="/{{ resolve . }}" title="{{ resolve . }}"> 9 + <img 10 + src="{{ tinyAvatar . }}" 11 + alt="" 12 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0 {{ $classes }}" 13 + /> 14 + </a> 15 {{ end }} 16 17 {{ if gt (len $all) 5 }}
+1 -1
appview/pages/templates/knots/index.html
··· 105 {{ define "docsButton" }} 106 <a 107 class="btn flex items-center gap-2" 108 - href="https://tangled.org/@tangled.org/core/blob/master/docs/knot-hosting.md"> 109 {{ i "book" "size-4" }} 110 docs 111 </a>
··· 105 {{ define "docsButton" }} 106 <a 107 class="btn flex items-center gap-2" 108 + href="https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide"> 109 {{ i "book" "size-4" }} 110 docs 111 </a>
+30 -17
appview/pages/templates/labels/fragments/label.html
··· 2 {{ $d := .def }} 3 {{ $v := .val }} 4 {{ $withPrefix := .withPrefix }} 5 - <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 - {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 8 - {{ $lhs := printf "%s" $d.Name }} 9 - {{ $rhs := "" }} 10 11 - {{ if not $d.ValueType.IsNull }} 12 - {{ if $d.ValueType.IsDidFormat }} 13 - {{ $v = resolve $v }} 14 - {{ end }} 15 16 - {{ if not $withPrefix }} 17 - {{ $lhs = "" }} 18 - {{ else }} 19 - {{ $lhs = printf "%s/" $d.Name }} 20 - {{ end }} 21 22 - {{ $rhs = printf "%s" $v }} 23 - {{ end }} 24 25 - {{ printf "%s%s" $lhs $rhs }} 26 - </span> 27 {{ end }} 28 29
··· 2 {{ $d := .def }} 3 {{ $v := .val }} 4 {{ $withPrefix := .withPrefix }} 5 6 + {{ $lhs := printf "%s" $d.Name }} 7 + {{ $rhs := "" }} 8 + {{ $isDid := false }} 9 + {{ $resolvedVal := "" }} 10 11 + {{ if not $d.ValueType.IsNull }} 12 + {{ $isDid = $d.ValueType.IsDidFormat }} 13 + {{ if $isDid }} 14 + {{ $resolvedVal = resolve $v }} 15 + {{ $v = $resolvedVal }} 16 + {{ end }} 17 + 18 + {{ if not $withPrefix }} 19 + {{ $lhs = "" }} 20 + {{ else }} 21 + {{ $lhs = printf "%s/" $d.Name }} 22 + {{ end }} 23 24 + {{ $rhs = printf "%s" $v }} 25 + {{ end }} 26 27 + {{ $chipClasses := "w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm" }} 28 29 + {{ if $isDid }} 30 + <a href="/{{ $resolvedVal }}" class="{{ $chipClasses }} no-underline hover:underline"> 31 + {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 32 + {{ printf "%s%s" $lhs $rhs }} 33 + </a> 34 + {{ else }} 35 + <span class="{{ $chipClasses }}"> 36 + {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 37 + {{ printf "%s%s" $lhs $rhs }} 38 + </span> 39 + {{ end }} 40 {{ end }} 41 42
+4
appview/pages/templates/layouts/base.html
··· 11 <script defer src="/static/htmx-ext-ws.min.js"></script> 12 <script defer src="/static/actor-typeahead.js" type="module"></script> 13 14 <!-- preconnect to image cdn --> 15 <link rel="preconnect" href="https://avatar.tangled.sh" /> 16 <link rel="preconnect" href="https://camo.tangled.sh" />
··· 11 <script defer src="/static/htmx-ext-ws.min.js"></script> 12 <script defer src="/static/actor-typeahead.js" type="module"></script> 13 14 + <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/> 15 + <link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> 16 + <link rel="apple-touch-icon" href="/static/logos/dolly.png"/> 17 + 18 <!-- preconnect to image cdn --> 19 <link rel="preconnect" href="https://avatar.tangled.sh" /> 20 <link rel="preconnect" href="https://camo.tangled.sh" />
+4 -4
appview/pages/templates/layouts/fragments/footer.html
··· 26 <div class="flex flex-col gap-1"> 27 <div class="{{ $headerStyle }}">resources</div> 28 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 </div> ··· 47 48 <!-- Right section --> 49 <div class="text-right"> 50 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 51 </div> 52 </div> 53 ··· 73 <div class="flex flex-col gap-1"> 74 <div class="{{ $headerStyle }}">resources</div> 75 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 </div> ··· 93 </div> 94 95 <div class="text-center"> 96 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 </div> 98 </div> 99 </div>
··· 26 <div class="flex flex-col gap-1"> 27 <div class="{{ $headerStyle }}">resources</div> 28 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 + <a href="https://docs.tangled.org" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 </div> ··· 47 48 <!-- Right section --> 49 <div class="text-right"> 50 + <div class="text-xs">&copy; 2026 Tangled Labs Oy. All rights reserved.</div> 51 </div> 52 </div> 53 ··· 73 <div class="flex flex-col gap-1"> 74 <div class="{{ $headerStyle }}">resources</div> 75 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 + <a href="https://docs.tangled.org" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 </div> ··· 93 </div> 94 95 <div class="text-center"> 96 + <div class="text-xs">&copy; 2026 Tangled Labs Oy. All rights reserved.</div> 97 </div> 98 </div> 99 </div>
+50 -16
appview/pages/templates/layouts/fragments/topbar.html
··· 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 6 - {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 7 - <span class="font-bold text-xl not-italic hidden md:inline">tangled</span> 8 - <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline"> 9 - alpha 10 - </span> 11 </a> 12 </div> 13 ··· 49 {{ define "profileDropdown" }} 50 <details class="relative inline-block text-left nav-dropdown"> 51 <summary class="cursor-pointer list-none flex items-center gap-1"> 52 - {{ $user := .Did }} 53 <img 54 src="{{ tinyAvatar $user }}" 55 alt="" ··· 57 /> 58 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 59 </summary> 60 - <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 61 - <a href="/{{ $user }}">profile</a> 62 - <a href="/{{ $user }}?tab=repos">repositories</a> 63 - <a href="/{{ $user }}?tab=strings">strings</a> 64 - <a href="/settings">settings</a> 65 - <a href="#" 66 - hx-post="/logout" 67 - hx-swap="none" 68 - class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 69 - logout 70 </a> 71 </div> 72 </details> 73
··· 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 6 + {{ template "fragments/logotypeSmall" }} 7 </a> 8 </div> 9 ··· 45 {{ define "profileDropdown" }} 46 <details class="relative inline-block text-left nav-dropdown"> 47 <summary class="cursor-pointer list-none flex items-center gap-1"> 48 + {{ $user := .Active.Did }} 49 <img 50 src="{{ tinyAvatar $user }}" 51 alt="" ··· 53 /> 54 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 55 </summary> 56 + <div class="absolute right-0 mt-4 p-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg z-50" style="width: 14rem;"> 57 + {{ $active := .Active.Did }} 58 + 59 + <div class="pb-2 mb-2 border-b border-gray-200 dark:border-gray-700"> 60 + <div class="flex items-center gap-2"> 61 + <img src="{{ tinyAvatar $active }}" alt="" class="rounded-full h-8 w-8 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 62 + <div class="flex-1 overflow-hidden"> 63 + <p class="font-medium text-sm truncate">{{ $active | resolve }}</p> 64 + <p class="text-xs text-green-600 dark:text-green-400">active</p> 65 + </div> 66 + </div> 67 + </div> 68 + 69 + {{ $others := .Accounts | otherAccounts $active }} 70 + {{ if $others }} 71 + <div class="pb-2 mb-2 border-b border-gray-200 dark:border-gray-700"> 72 + <p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Switch Account</p> 73 + {{ range $others }} 74 + <button 75 + type="button" 76 + hx-post="/account/switch" 77 + hx-vals='{"did": "{{ .Did }}"}' 78 + hx-swap="none" 79 + class="flex items-center gap-2 w-full py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-left" 80 + > 81 + <img src="{{ tinyAvatar .Did }}" alt="" class="rounded-full h-6 w-6 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 82 + <span class="text-sm truncate flex-1">{{ .Did | resolve }}</span> 83 + </button> 84 + {{ end }} 85 + </div> 86 + {{ end }} 87 + 88 + <a href="/login?mode=add_account" class="flex items-center gap-2 py-1 text-sm"> 89 + {{ i "plus" "w-4 h-4 flex-shrink-0" }} 90 + <span>Add another account</span> 91 </a> 92 + 93 + <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700 space-y-1"> 94 + <a href="/{{ $active }}" class="block py-1 text-sm">profile</a> 95 + <a href="/{{ $active }}?tab=repos" class="block py-1 text-sm">repositories</a> 96 + <a href="/{{ $active }}?tab=strings" class="block py-1 text-sm">strings</a> 97 + <a href="/settings" class="block py-1 text-sm">settings</a> 98 + <a href="#" 99 + hx-post="/logout" 100 + hx-swap="none" 101 + class="block py-1 text-sm text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 102 + logout 103 + </a> 104 + </div> 105 </div> 106 </details> 107
+1 -1
appview/pages/templates/layouts/repobase.html
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "content" }} 4 - <section id="repo-header" class="mb-4 p-2 dark:text-white"> 5 <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 6 <!-- left items --> 7 <div class="flex flex-col gap-2">
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "content" }} 4 + <section id="repo-header" class="mb-2 py-2 px-4 dark:text-white"> 5 <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 6 <!-- left items --> 7 <div class="flex flex-col gap-2">
+1 -18
appview/pages/templates/repo/commit.html
··· 116 {{ block "content" . }}{{ end }} 117 {{ end }} 118 119 - {{ block "contentAfterLayout" . }} 120 - <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 121 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 122 - {{ block "contentAfterLeft" . }} {{ end }} 123 - </div> 124 - <main class="col-span-1 md:col-span-10"> 125 - {{ block "contentAfter" . }}{{ end }} 126 - </main> 127 - </div> 128 - {{ end }} 129 </div> 130 {{ end }} 131 ··· 139 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 140 {{end}} 141 142 - {{ define "contentAfterLeft" }} 143 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 144 - {{ template "repo/fragments/diffOpts" .DiffOpts }} 145 - </div> 146 - <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 147 - {{ template "repo/fragments/diffChangedFiles" .Diff }} 148 - </div> 149 - {{end}}
··· 116 {{ block "content" . }}{{ end }} 117 {{ end }} 118 119 + {{ block "contentAfter" . }}{{ end }} 120 </div> 121 {{ end }} 122 ··· 130 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 131 {{end}} 132
+1 -19
appview/pages/templates/repo/compare/compare.html
··· 22 {{ block "content" . }}{{ end }} 23 {{ end }} 24 25 - {{ block "contentAfterLayout" . }} 26 - <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 27 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 28 - {{ block "contentAfterLeft" . }} {{ end }} 29 - </div> 30 - <main class="col-span-1 md:col-span-10"> 31 - {{ block "contentAfter" . }}{{ end }} 32 - </main> 33 - </div> 34 - {{ end }} 35 </div> 36 {{ end }} 37 ··· 44 {{ define "contentAfter" }} 45 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 46 {{end}} 47 - 48 - {{ define "contentAfterLeft" }} 49 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 50 - {{ template "repo/fragments/diffOpts" .DiffOpts }} 51 - </div> 52 - <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 53 - {{ template "repo/fragments/diffChangedFiles" .Diff }} 54 - </div> 55 - {{end}}
··· 22 {{ block "content" . }}{{ end }} 23 {{ end }} 24 25 + {{ block "contentAfter" . }}{{ end }} 26 </div> 27 {{ end }} 28 ··· 35 {{ define "contentAfter" }} 36 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 37 {{end}}
+1 -1
appview/pages/templates/repo/empty.html
··· 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 {{ $knot := .RepoInfo.Knot }} 28 {{ if eq $knot "knot1.tangled.sh" }} 29 - {{ $knot = "tangled.sh" }} 30 {{ end }} 31 <div class="w-full flex place-content-center"> 32 <div class="py-6 w-fit flex flex-col gap-4">
··· 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 {{ $knot := .RepoInfo.Knot }} 28 {{ if eq $knot "knot1.tangled.sh" }} 29 + {{ $knot = "tangled.org" }} 30 {{ end }} 31 <div class="w-full flex place-content-center"> 32 <div class="py-6 w-fit flex flex-col gap-4">
+81 -96
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 {{ define "repo/fragments/cloneDropdown" }} 2 - {{ $knot := .RepoInfo.Knot }} 3 - {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.org" }} 5 - {{ end }} 6 7 - <details id="clone-dropdown" class="relative inline-block text-left group"> 8 - <summary class="btn-create cursor-pointer list-none flex items-center gap-2"> 9 - {{ i "download" "w-4 h-4" }} 10 - <span class="hidden md:inline">code</span> 11 - <span class="group-open:hidden"> 12 - {{ i "chevron-down" "w-4 h-4" }} 13 - </span> 14 - <span class="hidden group-open:flex"> 15 - {{ i "chevron-up" "w-4 h-4" }} 16 - </span> 17 - </summary> 18 19 - <div class="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 drop-shadow-sm dark:text-white z-[9999]"> 20 - <div class="p-4"> 21 - <div class="mb-3"> 22 - <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Clone this repository</h3> 23 - </div> 24 25 - <!-- HTTPS Clone --> 26 - <div class="mb-3"> 27 - <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label> 28 - <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 29 - <code 30 - class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 - onclick="window.getSelection().selectAllChildren(this)" 32 - data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}" 33 - >https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code> 34 - <button 35 - onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 - class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 37 - title="Copy to clipboard" 38 - > 39 - {{ i "copy" "w-4 h-4" }} 40 - </button> 41 - </div> 42 - </div> 43 44 - <!-- SSH Clone --> 45 - <div class="mb-3"> 46 - {{ $repoOwnerHandle := resolve .RepoInfo.OwnerDid }} 47 - <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 48 - <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 49 - <code 50 - class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 51 - onclick="window.getSelection().selectAllChildren(this)" 52 - data-url="git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}" 53 - >git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}</code> 54 - <button 55 - onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 56 - class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 57 - title="Copy to clipboard" 58 - > 59 - {{ i "copy" "w-4 h-4" }} 60 - </button> 61 - </div> 62 - </div> 63 - 64 - <!-- Note for self-hosted --> 65 - <p class="text-xs text-gray-500 dark:text-gray-400"> 66 - For self-hosted knots, clone URLs may differ based on your setup. 67 - </p> 68 - 69 - <!-- Download Archive --> 70 - <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700"> 71 - <a 72 - href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 73 - class="flex items-center gap-2 px-3 py-2 text-sm" 74 - > 75 - {{ i "download" "w-4 h-4" }} 76 - Download tar.gz 77 - </a> 78 - </div> 79 - 80 - </div> 81 - </div> 82 - </details> 83 84 - <script> 85 - function copyToClipboard(button, text) { 86 - navigator.clipboard.writeText(text).then(() => { 87 - const originalContent = button.innerHTML; 88 - button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 89 - setTimeout(() => { 90 - button.innerHTML = originalContent; 91 - }, 2000); 92 - }); 93 - } 94 95 - // Close clone dropdown when clicking outside 96 - document.addEventListener('click', function(event) { 97 - const cloneDropdown = document.getElementById('clone-dropdown'); 98 - if (cloneDropdown && cloneDropdown.hasAttribute('open')) { 99 - if (!cloneDropdown.contains(event.target)) { 100 - cloneDropdown.removeAttribute('open'); 101 - } 102 - } 103 }); 104 - </script> 105 {{ end }}
··· 1 {{ define "repo/fragments/cloneDropdown" }} 2 + {{ $knot := .RepoInfo.Knot }} 3 + {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 + {{ end }} 6 7 + <button 8 + popovertarget="clone-dropdown" 9 + popovertargetaction="toggle" 10 + class="btn-create cursor-pointer list-none flex items-center gap-2 px-4"> 11 + {{ i "download" "w-4 h-4" }} 12 + <span class="hidden md:inline">code</span> 13 + </button> 14 + <div 15 + popover 16 + id="clone-dropdown" 17 + class=" 18 + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 19 + dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 20 + w-96 p-4 rounded drop-shadow overflow-visible"> 21 + <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-5">Clone this repository</h3> 22 23 + <!-- HTTPS Clone --> 24 + <div class="mb-3"> 25 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label> 26 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 27 + <code 28 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 29 + onclick="window.getSelection().selectAllChildren(this)" 30 + data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}" 31 + >https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code> 32 + <button 33 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 34 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 35 + title="Copy to clipboard" 36 + > 37 + {{ i "copy" "w-4 h-4" }} 38 + </button> 39 + </div> 40 + </div> 41 42 + <!-- SSH Clone --> 43 + <div class="mb-3"> 44 + {{ $repoOwnerHandle := resolve .RepoInfo.OwnerDid }} 45 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 46 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 47 + <code 48 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 49 + onclick="window.getSelection().selectAllChildren(this)" 50 + data-url="git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}" 51 + >git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}</code> 52 + <button 53 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 54 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 55 + title="Copy to clipboard" 56 + > 57 + {{ i "copy" "w-4 h-4" }} 58 + </button> 59 + </div> 60 + </div> 61 62 + <!-- Note for self-hosted --> 63 + <p class="text-xs text-gray-500 dark:text-gray-400"> 64 + For self-hosted knots, clone URLs may differ based on your setup. 65 + </p> 66 67 + <!-- Download Archive --> 68 + <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700"> 69 + <a 70 + href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 71 + class="flex items-center gap-2 px-3 py-2 text-sm" 72 + > 73 + {{ i "download" "w-4 h-4" }} 74 + Download tar.gz 75 + </a> 76 + </div> 77 + </div> 78 79 + <script> 80 + function copyToClipboard(button, text) { 81 + navigator.clipboard.writeText(text).then(() => { 82 + const originalContent = button.innerHTML; 83 + button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 84 + setTimeout(() => { 85 + button.innerHTML = originalContent; 86 + }, 2000); 87 }); 88 + } 89 + </script> 90 {{ end }}
+164 -43
appview/pages/templates/repo/fragments/diff.html
··· 1 {{ define "repo/fragments/diff" }} 2 {{ $diff := index . 0 }} 3 {{ $opts := index . 1 }} 4 5 - {{ $commit := $diff.Commit }} 6 - {{ $diff := $diff.Diff }} 7 - {{ $isSplit := $opts.Split }} 8 - {{ $this := $commit.This }} 9 - {{ $parent := $commit.Parent }} 10 - {{ $last := sub (len $diff) 1 }} 11 12 <div class="flex flex-col gap-4"> 13 - {{ if eq (len $diff) 0 }} 14 <div class="text-center text-gray-500 dark:text-gray-400 py-8"> 15 <p>No differences found between the selected revisions.</p> 16 </div> 17 {{ else }} 18 - {{ range $idx, $hunk := $diff }} 19 - {{ with $hunk }} 20 - <details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 21 - <summary class="list-none cursor-pointer sticky top-0"> 22 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 23 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 24 - <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 25 - <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 26 - {{ template "repo/fragments/diffStatPill" .Stats }} 27 28 - <div class="flex gap-2 items-center overflow-x-auto"> 29 - {{ if .IsDelete }} 30 - {{ .Name.Old }} 31 - {{ else if (or .IsCopy .IsRename) }} 32 - {{ .Name.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ .Name.New }} 33 - {{ else }} 34 - {{ .Name.New }} 35 - {{ end }} 36 - </div> 37 - </div> 38 - </div> 39 - </summary> 40 41 - <div class="transition-all duration-700 ease-in-out"> 42 - {{ if .IsBinary }} 43 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 44 - This is a binary file and will not be displayed. 45 - </p> 46 - {{ else }} 47 - {{ if $isSplit }} 48 - {{- template "repo/fragments/splitDiff" .Split -}} 49 {{ else }} 50 - {{- template "repo/fragments/unifiedDiff" . -}} 51 {{ end }} 52 - {{- end -}} 53 </div> 54 - </details> 55 - {{ end }} 56 - {{ end }} 57 - {{ end }} 58 - </div> 59 {{ end }}
··· 1 {{ define "repo/fragments/diff" }} 2 + <style> 3 + #filesToggle:checked ~ div label[for="filesToggle"] .show-text { display: none; } 4 + #filesToggle:checked ~ div label[for="filesToggle"] .hide-text { display: inline; } 5 + #filesToggle:not(:checked) ~ div label[for="filesToggle"] .hide-text { display: none; } 6 + #filesToggle:checked ~ div div#files { width: fit-content; max-width: 15vw; margin-right: 1rem; } 7 + #filesToggle:not(:checked) ~ div div#files { width: 0; display: hidden; margin-right: 0; } 8 + </style> 9 + 10 + {{ template "diffTopbar" . }} 11 + {{ block "diffLayout" . }} {{ end }} 12 + {{ end }} 13 + 14 + {{ define "diffTopbar" }} 15 {{ $diff := index . 0 }} 16 {{ $opts := index . 1 }} 17 18 + {{ block "filesCheckbox" $ }} {{ end }} 19 + {{ block "subsCheckbox" $ }} {{ end }} 20 + 21 + <!-- top bar --> 22 + <div class="sticky top-0 z-30 bg-slate-100 dark:bg-gray-900 flex items-center gap-2 col-span-full h-12 p-2"> 23 + <!-- left panel toggle --> 24 + {{ template "filesToggle" . }} 25 26 + <!-- stats --> 27 + {{ $stat := $diff.Stats }} 28 + {{ $count := len $diff.ChangedFiles }} 29 + {{ template "repo/fragments/diffStatPill" $stat }} 30 + {{ $count }} changed file{{ if ne $count 1 }}s{{ end }} 31 + 32 + <!-- spacer --> 33 + <div class="flex-grow"></div> 34 + 35 + <!-- collapse diffs --> 36 + {{ template "collapseToggle" }} 37 + 38 + <!-- diff options --> 39 + {{ template "repo/fragments/diffOpts" $opts }} 40 + 41 + <!-- right panel toggle --> 42 + {{ block "subsToggle" $ }} {{ end }} 43 + </div> 44 + 45 + {{ end }} 46 + 47 + {{ define "diffLayout" }} 48 + {{ $diff := index . 0 }} 49 + {{ $opts := index . 1 }} 50 + 51 + <div class="flex col-span-full flex-grow"> 52 + <!-- left panel --> 53 + <div id="files" class="w-0 hidden md:block overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 54 + <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 55 + {{ template "repo/fragments/fileTree" $diff.FileTree }} 56 + </section> 57 + </div> 58 + 59 + <!-- main content --> 60 + <div class="flex-1 min-w-0 sticky top-12 pb-12"> 61 + {{ template "diffFiles" (list $diff $opts) }} 62 + </div> 63 + 64 + </div> 65 + {{ end }} 66 + 67 + {{ define "diffFiles" }} 68 + {{ $diff := index . 0 }} 69 + {{ $opts := index . 1 }} 70 + {{ $files := $diff.ChangedFiles }} 71 + {{ $isSplit := $opts.Split }} 72 <div class="flex flex-col gap-4"> 73 + {{ if eq (len $files) 0 }} 74 <div class="text-center text-gray-500 dark:text-gray-400 py-8"> 75 <p>No differences found between the selected revisions.</p> 76 </div> 77 {{ else }} 78 + {{ range $idx, $file := $files }} 79 + {{ template "diffFile" (list $idx $file $isSplit) }} 80 + {{ end }} 81 + {{ end }} 82 + </div> 83 + {{ end }} 84 85 + {{ define "diffFile" }} 86 + {{ $idx := index . 0 }} 87 + {{ $file := index . 1 }} 88 + {{ $isSplit := index . 2 }} 89 + {{ with $file }} 90 + <details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 91 + <summary class="list-none cursor-pointer sticky top-12 group-open:border-b border-gray-200 dark:border-gray-700"> 92 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 93 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 94 + <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 95 + <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 96 + {{ template "repo/fragments/diffStatPill" .Stats }} 97 98 + <div class="flex gap-2 items-center overflow-x-auto"> 99 + {{ $n := .Names }} 100 + {{ if and $n.New $n.Old (ne $n.New $n.Old)}} 101 + {{ $n.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ $n.New }} 102 + {{ else if $n.New }} 103 + {{ $n.New }} 104 {{ else }} 105 + {{ $n.Old }} 106 {{ end }} 107 + </div> 108 </div> 109 + </div> 110 + </summary> 111 + 112 + <div class="transition-all duration-700 ease-in-out"> 113 + {{ $reason := .CanRender }} 114 + {{ if $reason }} 115 + <p class="text-center text-gray-400 dark:text-gray-500 p-4">{{ $reason }}</p> 116 + {{ else }} 117 + {{ if $isSplit }} 118 + {{- template "repo/fragments/splitDiff" .Split -}} 119 + {{ else }} 120 + {{- template "repo/fragments/unifiedDiff" . -}} 121 + {{ end }} 122 + {{- end -}} 123 + </div> 124 + </details> 125 + {{ end }} 126 + {{ end }} 127 + 128 + {{ define "filesCheckbox" }} 129 + <input type="checkbox" id="filesToggle" class="peer/files hidden" checked/> 130 + {{ end }} 131 + 132 + {{ define "filesToggle" }} 133 + <label title="Toggle filetree panel" for="filesToggle" class="hidden md:inline-flex items-center justify-center rounded cursor-pointer text-normal font-normal normalcase"> 134 + <span class="show-text">{{ i "panel-left-open" "size-4" }}</span> 135 + <span class="hide-text">{{ i "panel-left-close" "size-4" }}</span> 136 + </label> 137 + {{ end }} 138 + 139 + {{ define "collapseToggle" }} 140 + <label 141 + title="Expand/Collapse diffs" 142 + for="collapseToggle" 143 + class="btn font-normal normal-case p-2" 144 + > 145 + <input type="checkbox" id="collapseToggle" class="peer/collapse hidden" checked/> 146 + <span class="peer-checked/collapse:hidden inline-flex items-center gap-2"> 147 + {{ i "fold-vertical" "w-4 h-4" }} 148 + <span class="hidden md:inline">expand all</span> 149 + </span> 150 + <span class="peer-checked/collapse:inline-flex hidden flex items-center gap-2"> 151 + {{ i "unfold-vertical" "w-4 h-4" }} 152 + <span class="hidden md:inline">collapse all</span> 153 + </span> 154 + </label> 155 + <script> 156 + document.addEventListener('DOMContentLoaded', function() { 157 + const checkbox = document.getElementById('collapseToggle'); 158 + const details = document.querySelectorAll('details[id^="file-"]'); 159 + 160 + checkbox.addEventListener('change', function() { 161 + details.forEach(detail => { 162 + detail.open = checkbox.checked; 163 + }); 164 + }); 165 + 166 + details.forEach(detail => { 167 + detail.addEventListener('toggle', function() { 168 + const allOpen = Array.from(details).every(d => d.open); 169 + const allClosed = Array.from(details).every(d => !d.open); 170 + 171 + if (allOpen) { 172 + checkbox.checked = true; 173 + } else if (allClosed) { 174 + checkbox.checked = false; 175 + } 176 + }); 177 + }); 178 + }); 179 + </script> 180 {{ end }}
-13
appview/pages/templates/repo/fragments/diffChangedFiles.html
··· 1 - {{ define "repo/fragments/diffChangedFiles" }} 2 - {{ $stat := .Stat }} 3 - {{ $fileTree := fileTree .ChangedFiles }} 4 - <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 5 - <div class="diff-stat"> 6 - <div class="flex gap-2 items-center"> 7 - <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 8 - {{ template "repo/fragments/diffStatPill" $stat }} 9 - </div> 10 - {{ template "repo/fragments/fileTree" $fileTree }} 11 - </div> 12 - </section> 13 - {{ end }}
···
+22 -25
appview/pages/templates/repo/fragments/diffOpts.html
··· 1 {{ define "repo/fragments/diffOpts" }} 2 - <section class="flex flex-col gap-2 overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 3 - <strong class="text-sm uppercase dark:text-gray-200">options</strong> 4 - {{ $active := "unified" }} 5 - {{ if .Split }} 6 - {{ $active = "split" }} 7 - {{ end }} 8 9 - {{ $unified := 10 - (dict 11 - "Key" "unified" 12 - "Value" "unified" 13 - "Icon" "square-split-vertical" 14 - "Meta" "") }} 15 - {{ $split := 16 - (dict 17 - "Key" "split" 18 - "Value" "split" 19 - "Icon" "square-split-horizontal" 20 - "Meta" "") }} 21 - {{ $values := list $unified $split }} 22 23 - {{ template "fragments/tabSelector" 24 - (dict 25 - "Name" "diff" 26 - "Values" $values 27 - "Active" $active) }} 28 - </section> 29 {{ end }} 30
··· 1 {{ define "repo/fragments/diffOpts" }} 2 + {{ $active := "unified" }} 3 + {{ if .Split }} 4 + {{ $active = "split" }} 5 + {{ end }} 6 7 + {{ $unified := 8 + (dict 9 + "Key" "unified" 10 + "Value" "unified" 11 + "Icon" "square-split-vertical" 12 + "Meta" "") }} 13 + {{ $split := 14 + (dict 15 + "Key" "split" 16 + "Value" "split" 17 + "Icon" "square-split-horizontal" 18 + "Meta" "") }} 19 + {{ $values := list $unified $split }} 20 21 + {{ template "fragments/tabSelector" 22 + (dict 23 + "Name" "diff" 24 + "Values" $values 25 + "Active" $active) }} 26 {{ end }} 27
-67
appview/pages/templates/repo/fragments/interdiff.html
··· 1 - {{ define "repo/fragments/interdiff" }} 2 - {{ $repo := index . 0 }} 3 - {{ $x := index . 1 }} 4 - {{ $opts := index . 2 }} 5 - {{ $fileTree := fileTree $x.AffectedFiles }} 6 - {{ $diff := $x.Files }} 7 - {{ $last := sub (len $diff) 1 }} 8 - {{ $isSplit := $opts.Split }} 9 - 10 - <div class="flex flex-col gap-4"> 11 - {{ range $idx, $hunk := $diff }} 12 - {{ with $hunk }} 13 - <details {{ if not (.Status.IsOnlyInOne) }}open{{end}} id="file-{{ .Name }}" class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 14 - <summary class="list-none cursor-pointer sticky top-0"> 15 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 16 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 17 - <div class="flex gap-1 items-center" style="direction: ltr;"> 18 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 19 - {{ if .Status.IsOk }} 20 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span> 21 - {{ else if .Status.IsUnchanged }} 22 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span> 23 - {{ else if .Status.IsOnlyInOne }} 24 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span> 25 - {{ else if .Status.IsOnlyInTwo }} 26 - <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span> 27 - {{ else if .Status.IsRebased }} 28 - <span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span> 29 - {{ else }} 30 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span> 31 - {{ end }} 32 - </div> 33 - 34 - <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">{{ .Name }}</div> 35 - </div> 36 - 37 - </div> 38 - </summary> 39 - 40 - <div class="transition-all duration-700 ease-in-out"> 41 - {{ if .Status.IsUnchanged }} 42 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 43 - This file has not been changed. 44 - </p> 45 - {{ else if .Status.IsRebased }} 46 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 47 - This patch was likely rebased, as context lines do not match. 48 - </p> 49 - {{ else if .Status.IsError }} 50 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 51 - Failed to calculate interdiff for this file. 52 - </p> 53 - {{ else }} 54 - {{ if $isSplit }} 55 - {{- template "repo/fragments/splitDiff" .Split -}} 56 - {{ else }} 57 - {{- template "repo/fragments/unifiedDiff" . -}} 58 - {{ end }} 59 - {{- end -}} 60 - </div> 61 - 62 - </details> 63 - {{ end }} 64 - {{ end }} 65 - </div> 66 - {{ end }} 67 -
···
-11
appview/pages/templates/repo/fragments/interdiffFiles.html
··· 1 - {{ define "repo/fragments/interdiffFiles" }} 2 - {{ $fileTree := fileTree .AffectedFiles }} 3 - <section class="px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm"> 4 - <div class="diff-stat"> 5 - <div class="flex gap-2 items-center"> 6 - <strong class="text-sm uppercase dark:text-gray-200">files</strong> 7 - </div> 8 - {{ template "repo/fragments/fileTree" $fileTree }} 9 - </div> 10 - </section> 11 - {{ end }}
···
+35 -35
appview/pages/templates/repo/fragments/splitDiff.html
··· 3 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 7 {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 10 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 11 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 12 <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700"> 13 - <pre class="overflow-x-auto col-span-1"><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> 14 {{- range .LeftLines -}} 15 {{- if .IsEmpty -}} 16 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 18 - <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 19 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 20 - </div> 21 {{- else if eq .Op.String "-" -}} 22 - <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 24 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 25 - <div class="px-2">{{ .Content }}</div> 26 - </div> 27 {{- else if eq .Op.String " " -}} 28 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 30 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 31 - <div class="px-2">{{ .Content }}</div> 32 - </div> 33 {{- end -}} 34 {{- end -}} 35 - {{- end -}}</div></div></pre> 36 37 - <pre class="overflow-x-auto col-span-1"><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> 38 {{- range .RightLines -}} 39 {{- if .IsEmpty -}} 40 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 42 - <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 43 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 44 - </div> 45 {{- else if eq .Op.String "+" -}} 46 - <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 48 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 49 - <div class="px-2" >{{ .Content }}</div> 50 - </div> 51 {{- else if eq .Op.String " " -}} 52 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 54 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 55 - <div class="px-2">{{ .Content }}</div> 56 - </div> 57 {{- end -}} 58 {{- end -}} 59 - {{- end -}}</div></div></pre> 60 </div> 61 {{ end }}
··· 3 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 + {{- $containerStyle := "inline-flex w-full items-center target:bg-yellow-200 target:dark:bg-yellow-700 scroll-mt-48" -}} 7 {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 10 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 11 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 12 <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700"> 13 + <div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 14 {{- range .LeftLines -}} 15 {{- if .IsEmpty -}} 16 + <span class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span> 18 + <span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span> 19 + <span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span> 20 + </span> 21 {{- else if eq .Op.String "-" -}} 22 + <span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 + <span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span> 24 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 25 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 26 + </span> 27 {{- else if eq .Op.String " " -}} 28 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 + <span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span> 30 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 31 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 32 + </span> 33 {{- end -}} 34 {{- end -}} 35 + {{- end -}}</div></div></div> 36 37 + <div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 38 {{- range .RightLines -}} 39 {{- if .IsEmpty -}} 40 + <span class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span> 42 + <span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span> 43 + <span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span> 44 + </span> 45 {{- else if eq .Op.String "+" -}} 46 + <span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></span> 48 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 49 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 50 + </span> 51 {{- else if eq .Op.String " " -}} 52 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a> </span> 54 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 55 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 56 + </span> 57 {{- end -}} 58 {{- end -}} 59 + {{- end -}}</div></div></div> 60 </div> 61 {{ end }}
+21 -22
appview/pages/templates/repo/fragments/unifiedDiff.html
··· 1 {{ define "repo/fragments/unifiedDiff" }} 2 {{ $name := .Id }} 3 - <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> 4 {{- $oldStart := .OldPosition -}} 5 {{- $newStart := .NewPosition -}} 6 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 7 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 {{- $lineNrSepStyle1 := "" -}} 9 {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 11 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 14 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 15 {{- range .Lines -}} 16 {{- if eq .Op.String "+" -}} 17 - <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 19 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 20 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 21 - <div class="px-2">{{ .Line }}</div> 22 - </div> 23 {{- $newStart = add64 $newStart 1 -}} 24 {{- end -}} 25 {{- if eq .Op.String "-" -}} 26 - <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 28 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 29 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 30 - <div class="px-2">{{ .Line }}</div> 31 - </div> 32 {{- $oldStart = add64 $oldStart 1 -}} 33 {{- end -}} 34 {{- if eq .Op.String " " -}} 35 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div> 37 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div> 38 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 39 - <div class="px-2">{{ .Line }}</div> 40 - </div> 41 {{- $newStart = add64 $newStart 1 -}} 42 {{- $oldStart = add64 $oldStart 1 -}} 43 {{- end -}} 44 {{- end -}} 45 - {{- end -}}</div></div></pre> 46 {{ end }} 47 -
··· 1 {{ define "repo/fragments/unifiedDiff" }} 2 {{ $name := .Id }} 3 + <div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 4 {{- $oldStart := .OldPosition -}} 5 {{- $newStart := .NewPosition -}} 6 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 7 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 {{- $lineNrSepStyle1 := "" -}} 9 {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 + {{- $containerStyle := "inline-flex w-full items-center target:bg-yellow-200 target:dark:bg-yellow-700 scroll-mt-48" -}} 11 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 14 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 15 {{- range .Lines -}} 16 {{- if eq .Op.String "+" -}} 17 + <span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></span> 19 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></span> 20 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 21 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 22 + </span> 23 {{- $newStart = add64 $newStart 1 -}} 24 {{- end -}} 25 {{- if eq .Op.String "-" -}} 26 + <span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></span> 28 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></span> 29 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 30 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 31 + </span> 32 {{- $oldStart = add64 $oldStart 1 -}} 33 {{- end -}} 34 {{- if eq .Op.String " " -}} 35 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></span> 37 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></span> 38 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 39 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 40 + </span> 41 {{- $newStart = add64 $newStart 1 -}} 42 {{- $oldStart = add64 $oldStart 1 -}} 43 {{- end -}} 44 {{- end -}} 45 + {{- end -}}</div></div></div> 46 {{ end }}
+4 -9
appview/pages/templates/repo/index.html
··· 14 {{ end }} 15 <div class="flex items-center justify-between pb-5"> 16 {{ block "branchSelector" . }}{{ end }} 17 - <div class="flex md:hidden items-center gap-3"> 18 - <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold"> 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 </a> 21 - <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1 font-bold"> 22 {{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }} 23 </a> 24 - <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1 font-bold"> 25 {{ i "tags" "w-4" "h-4" }} {{ len .Tags }} 26 </a> 27 {{ template "repo/fragments/cloneDropdown" . }} ··· 109 {{ i "git-compare" "w-4 h-4" }} 110 </a> 111 </div> 112 - </div> 113 - 114 - <!-- Clone dropdown in top right --> 115 - <div class="hidden md:flex items-center "> 116 - {{ template "repo/fragments/cloneDropdown" . }} 117 </div> 118 </div> 119 {{ end }}
··· 14 {{ end }} 15 <div class="flex items-center justify-between pb-5"> 16 {{ block "branchSelector" . }}{{ end }} 17 + <div class="flex items-center gap-3"> 18 + <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex md:hidden items-center text-sm gap-1 font-bold"> 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 </a> 21 + <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex md:hidden items-center text-sm gap-1 font-bold"> 22 {{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }} 23 </a> 24 + <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex md:hidden items-center text-sm gap-1 font-bold"> 25 {{ i "tags" "w-4" "h-4" }} {{ len .Tags }} 26 </a> 27 {{ template "repo/fragments/cloneDropdown" . }} ··· 109 {{ i "git-compare" "w-4 h-4" }} 110 </a> 111 </div> 112 </div> 113 </div> 114 {{ end }}
+35 -22
appview/pages/templates/repo/issues/fragments/commentList.html
··· 1 {{ define "repo/issues/fragments/commentList" }} 2 - <div class="flex flex-col gap-8"> 3 {{ range $item := .CommentList }} 4 {{ template "commentListing" (list $ .) }} 5 {{ end }} ··· 19 <div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50"> 20 {{ template "topLevelComment" $params }} 21 22 - <div class="relative ml-4 border-l-2 border-gray-200 dark:border-gray-700"> 23 {{ range $index, $reply := $comment.Replies }} 24 - <div class="relative "> 25 - <!-- Horizontal connector --> 26 - <div class="absolute left-0 top-6 w-4 h-1 bg-gray-200 dark:bg-gray-700"></div> 27 - 28 - <div class="pl-2"> 29 - {{ 30 - template "replyComment" 31 - (dict 32 - "RepoInfo" $root.RepoInfo 33 - "LoggedInUser" $root.LoggedInUser 34 - "Issue" $root.Issue 35 - "Comment" $reply) 36 - }} 37 - </div> 38 </div> 39 {{ end }} 40 </div> ··· 44 {{ end }} 45 46 {{ define "topLevelComment" }} 47 - <div class="rounded px-6 py-4 bg-white dark:bg-gray-800"> 48 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 49 - {{ template "repo/issues/fragments/issueCommentBody" . }} 50 </div> 51 {{ end }} 52 53 {{ define "replyComment" }} 54 - <div class="p-4 w-full mx-auto overflow-hidden"> 55 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 56 - {{ template "repo/issues/fragments/issueCommentBody" . }} 57 </div> 58 {{ end }}
··· 1 {{ define "repo/issues/fragments/commentList" }} 2 + <div class="flex flex-col gap-4"> 3 {{ range $item := .CommentList }} 4 {{ template "commentListing" (list $ .) }} 5 {{ end }} ··· 19 <div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50"> 20 {{ template "topLevelComment" $params }} 21 22 + <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 23 {{ range $index, $reply := $comment.Replies }} 24 + <div class="-ml-4"> 25 + {{ 26 + template "replyComment" 27 + (dict 28 + "RepoInfo" $root.RepoInfo 29 + "LoggedInUser" $root.LoggedInUser 30 + "Issue" $root.Issue 31 + "Comment" $reply) 32 + }} 33 </div> 34 {{ end }} 35 </div> ··· 39 {{ end }} 40 41 {{ define "topLevelComment" }} 42 + <div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2 "> 43 + <div class="flex-shrink-0"> 44 + <img 45 + src="{{ tinyAvatar .Comment.Did }}" 46 + alt="" 47 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 48 + /> 49 + </div> 50 + <div class="flex-1 min-w-0"> 51 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 52 + {{ template "repo/issues/fragments/issueCommentBody" . }} 53 + </div> 54 </div> 55 {{ end }} 56 57 {{ define "replyComment" }} 58 + <div class="py-4 pr-4 w-full mx-auto overflow-hidden flex gap-2 "> 59 + <div class="flex-shrink-0"> 60 + <img 61 + src="{{ tinyAvatar .Comment.Did }}" 62 + alt="" 63 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 64 + /> 65 + </div> 66 + <div class="flex-1 min-w-0"> 67 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 68 + {{ template "repo/issues/fragments/issueCommentBody" . }} 69 + </div> 70 </div> 71 {{ end }}
-63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
··· 1 - {{ define "repo/issues/fragments/globalIssueListing" }} 2 - <div class="flex flex-col gap-2"> 3 - {{ range .Issues }} 4 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 - <div class="pb-2 mb-3"> 6 - <div class="flex items-center gap-3 mb-2"> 7 - <a 8 - href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 - class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 - > 11 - {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 - </a> 13 - </div> 14 - <a 15 - href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 - class="no-underline hover:underline" 17 - > 18 - {{ .Title | description }} 19 - <span class="text-gray-500">#{{ .IssueId }}</span> 20 - </a> 21 - </div> 22 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 - {{ $icon := "ban" }} 25 - {{ $state := "closed" }} 26 - {{ if .Open }} 27 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 - {{ $icon = "circle-dot" }} 29 - {{ $state = "open" }} 30 - {{ end }} 31 - 32 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 - <span class="text-white dark:text-white">{{ $state }}</span> 35 - </span> 36 - 37 - <span class="ml-1"> 38 - {{ template "user/fragments/picHandleLink" .Did }} 39 - </span> 40 - 41 - <span class="before:content-['ยท']"> 42 - {{ template "repo/fragments/time" .Created }} 43 - </span> 44 - 45 - <span class="before:content-['ยท']"> 46 - {{ $s := "s" }} 47 - {{ if eq (len .Comments) 1 }} 48 - {{ $s = "" }} 49 - {{ end }} 50 - <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 - </span> 52 - 53 - {{ $state := .Labels }} 54 - {{ range $k, $d := $.LabelDefs }} 55 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 - {{ end }} 58 - {{ end }} 59 - </div> 60 - </div> 61 - {{ end }} 62 - </div> 63 - {{ end }}
···
+2 -1
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 {{ define "repo/issues/fragments/issueCommentHeader" }} 2 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 - {{ template "user/fragments/picHandleLink" .Comment.Did }} 4 {{ template "hats" $ }} 5 {{ template "timestamp" . }} 6 {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 7 {{ if and $isCommentOwner (not .Comment.Deleted) }}
··· 1 {{ define "repo/issues/fragments/issueCommentHeader" }} 2 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 + {{ resolve .Comment.Did }} 4 {{ template "hats" $ }} 5 + <span class="before:content-['ยท']"></span> 6 {{ template "timestamp" . }} 7 {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 8 {{ if and $isCommentOwner (not .Comment.Deleted) }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 21 {{ $state = "open" }} 22 {{ end }} 23 24 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 - <span class="text-white dark:text-white">{{ $state }}</span> 27 </span> 28 29 <span class="ml-1">
··· 21 {{ $state = "open" }} 22 {{ end }} 23 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }}"> 25 {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 + <span class="text-white dark:text-white text-sm">{{ $state }}</span> 27 </span> 28 29 <span class="ml-1">
+1 -1
appview/pages/templates/repo/issues/fragments/putIssue.html
··· 18 <textarea 19 name="body" 20 id="body" 21 - rows="6" 22 class="w-full resize-y" 23 placeholder="Describe your issue. Markdown is supported." 24 >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
··· 18 <textarea 19 name="body" 20 id="body" 21 + rows="15" 22 class="w-full resize-y" 23 placeholder="Describe your issue. Markdown is supported." 24 >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
+3 -3
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
··· 1 {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 - <div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 {{ if .LoggedInUser }} 4 <img 5 src="{{ tinyAvatar .LoggedInUser.Did }}" 6 alt="" 7 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 8 /> 9 {{ end }} 10 <input 11 - class="w-full py-2 border-none focus:outline-none" 12 placeholder="Leave a reply..." 13 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 14 hx-trigger="focus"
··· 1 {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 + <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 {{ if .LoggedInUser }} 4 <img 5 src="{{ tinyAvatar .LoggedInUser.Did }}" 6 alt="" 7 + class="rounded-full size-8 mr-1 border-2 border-gray-300 dark:border-gray-700" 8 /> 9 {{ end }} 10 <input 11 + class="w-full p-0 border-none focus:outline-none" 12 placeholder="Leave a reply..." 13 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 14 hx-trigger="focus"
+5 -5
appview/pages/templates/repo/issues/issue.html
··· 58 {{ $icon = "circle-dot" }} 59 {{ end }} 60 <div class="inline-flex items-center gap-2"> 61 - <div id="state" 62 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 63 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 64 - <span class="text-white">{{ .Issue.State }}</span> 65 - </div> 66 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 67 opened by 68 {{ template "user/fragments/picHandleLink" .Issue.Did }}
··· 58 {{ $icon = "circle-dot" }} 59 {{ end }} 60 <div class="inline-flex items-center gap-2"> 61 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }}"> 62 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 63 + <span class="text-white dark:text-white text-sm">{{ .Issue.State }}</span> 64 + </span> 65 + 66 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 67 opened by 68 {{ template "user/fragments/picHandleLink" .Issue.Did }}
+10 -103
appview/pages/templates/repo/issues/issues.html
··· 71 <div class="mt-2"> 72 {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 73 </div> 74 - {{if gt .IssueCount .Page.Limit }} 75 - {{ block "pagination" . }} {{ end }} 76 - {{ end }} 77 - {{ end }} 78 - 79 - {{ define "pagination" }} 80 - <div class="flex justify-center items-center mt-4 gap-2"> 81 - {{ $currentState := "closed" }} 82 - {{ if .FilteringByOpen }} 83 - {{ $currentState = "open" }} 84 - {{ end }} 85 - 86 - {{ $prev := .Page.Previous.Offset }} 87 - {{ $next := .Page.Next.Offset }} 88 - {{ $lastPage := sub .IssueCount (mod .IssueCount .Page.Limit) }} 89 - 90 - <a 91 - class=" 92 - btn flex items-center gap-2 no-underline hover:no-underline 93 - dark:text-white dark:hover:bg-gray-700 94 - {{ if le .Page.Offset 0 }} 95 - cursor-not-allowed opacity-50 96 - {{ end }} 97 - " 98 - {{ if gt .Page.Offset 0 }} 99 - hx-boost="true" 100 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}" 101 {{ end }} 102 - > 103 - {{ i "chevron-left" "w-4 h-4" }} 104 - previous 105 - </a> 106 - 107 - <!-- dont show first page if current page is first page --> 108 - {{ if gt .Page.Offset 0 }} 109 - <a 110 - hx-boost="true" 111 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset=0&limit={{ .Page.Limit }}" 112 - > 113 - 1 114 - </a> 115 - {{ end }} 116 - 117 - <!-- if previous page is not first or second page (prev > limit) --> 118 - {{ if gt $prev .Page.Limit }} 119 - <span>...</span> 120 {{ end }} 121 - 122 - <!-- if previous page is not the first page --> 123 - {{ if gt $prev 0 }} 124 - <a 125 - hx-boost="true" 126 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}" 127 - > 128 - {{ add (div $prev .Page.Limit) 1 }} 129 - </a> 130 - {{ end }} 131 - 132 - <!-- current page. this is always visible --> 133 - <span class="font-bold"> 134 - {{ add (div .Page.Offset .Page.Limit) 1 }} 135 - </span> 136 - 137 - <!-- if next page is not last page --> 138 - {{ if lt $next $lastPage }} 139 - <a 140 - hx-boost="true" 141 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}" 142 - > 143 - {{ add (div $next .Page.Limit) 1 }} 144 - </a> 145 - {{ end }} 146 - 147 - <!-- if next page is not second last or last page (next < issues - 2 * limit) --> 148 - {{ if lt ($next) (sub .IssueCount (mul (2) .Page.Limit)) }} 149 - <span>...</span> 150 - {{ end }} 151 - 152 - <!-- if its not the last page --> 153 - {{ if lt .Page.Offset $lastPage }} 154 - <a 155 - hx-boost="true" 156 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $lastPage }}&limit={{ .Page.Limit }}" 157 - > 158 - {{ add (div $lastPage .Page.Limit) 1 }} 159 - </a> 160 - {{ end }} 161 - 162 - <a 163 - class=" 164 - btn flex items-center gap-2 no-underline hover:no-underline 165 - dark:text-white dark:hover:bg-gray-700 166 - {{ if ne (len .Issues) .Page.Limit }} 167 - cursor-not-allowed opacity-50 168 - {{ end }} 169 - " 170 - {{ if eq (len .Issues) .Page.Limit }} 171 - hx-boost="true" 172 - href="/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}" 173 - {{ end }} 174 - > 175 - next 176 - {{ i "chevron-right" "w-4 h-4" }} 177 - </a> 178 - </div> 179 {{ end }}
··· 71 <div class="mt-2"> 72 {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 73 </div> 74 + {{if gt .IssueCount .Page.Limit }} 75 + {{ $state := "closed" }} 76 + {{ if .FilteringByOpen }} 77 + {{ $state = "open" }} 78 {{ end }} 79 + {{ template "fragments/pagination" (dict 80 + "Page" .Page 81 + "TotalCount" .IssueCount 82 + "BasePath" (printf "/%s/issues" .RepoInfo.FullName) 83 + "QueryParams" (printf "state=%s&q=%s" $state .FilterQuery) 84 + ) }} 85 {{ end }} 86 {{ end }}
+60 -69
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
··· 1 {{ define "repo/pipelines/fragments/pipelineSymbol" }} 2 - <div class="cursor-pointer"> 3 - {{ $c := .Counts }} 4 - {{ $statuses := .Statuses }} 5 - {{ $total := len $statuses }} 6 - {{ $success := index $c "success" }} 7 - {{ $fail := index $c "failed" }} 8 - {{ $timeout := index $c "timeout" }} 9 - {{ $empty := eq $total 0 }} 10 - {{ $allPass := eq $success $total }} 11 - {{ $allFail := eq $fail $total }} 12 - {{ $allTimeout := eq $timeout $total }} 13 - 14 - {{ if $empty }} 15 - <div class="flex gap-1 items-center"> 16 - {{ i "hourglass" "size-4 text-gray-600 dark:text-gray-400 " }} 17 - <span>0/{{ $total }}</span> 18 - </div> 19 - {{ else if $allPass }} 20 - <div class="flex gap-1 items-center"> 21 - {{ i "check" "size-4 text-green-600" }} 22 - <span>{{ $total }}/{{ $total }}</span> 23 - </div> 24 - {{ else if $allFail }} 25 - <div class="flex gap-1 items-center"> 26 - {{ i "x" "size-4 text-red-500" }} 27 - <span>0/{{ $total }}</span> 28 - </div> 29 - {{ else if $allTimeout }} 30 - <div class="flex gap-1 items-center"> 31 - {{ i "clock-alert" "size-4 text-orange-500" }} 32 - <span>0/{{ $total }}</span> 33 - </div> 34 {{ else }} 35 - {{ $radius := f64 8 }} 36 - {{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }} 37 - {{ $offset := 0.0 }} 38 - <div class="flex gap-1 items-center"> 39 - <svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20"> 40 - <circle cx="10" cy="10" r="{{ $radius }}" fill="none" stroke="#f3f4f633" stroke-width="2"/> 41 42 - {{ range $kind, $count := $c }} 43 - {{ $color := "" }} 44 - {{ if or (eq $kind "pending") (eq $kind "running") }} 45 - {{ $color = "#eab308" }} {{/* amber-500 */}} 46 - {{ else if eq $kind "success" }} 47 - {{ $color = "#10b981" }} {{/* green-500 */}} 48 - {{ else if eq $kind "cancelled" }} 49 - {{ $color = "#6b7280" }} {{/* gray-500 */}} 50 - {{ else if eq $kind "timeout" }} 51 - {{ $color = "#fb923c" }} {{/* orange-400 */}} 52 - {{ else }} 53 - {{ $color = "#ef4444" }} {{/* red-500 for failed or unknown */}} 54 - {{ end }} 55 56 - {{ $percent := divf64 (f64 $count) (f64 $total) }} 57 - {{ $length := mulf64 $percent $circumference }} 58 - 59 - <circle 60 - cx="10" cy="10" r="{{ $radius }}" 61 - fill="none" 62 - stroke="{{ $color }}" 63 - stroke-width="2" 64 - stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}" 65 - stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}" 66 - /> 67 - {{ $offset = addf64 $offset $length }} 68 - {{ end }} 69 - </svg> 70 - <span>{{ $success }}/{{ $total }}</span> 71 - </div> 72 - {{ end }} 73 - </div> 74 {{ end }}
··· 1 {{ define "repo/pipelines/fragments/pipelineSymbol" }} 2 + <div class="cursor-pointer flex gap-2 items-center"> 3 + {{ template "symbol" .Pipeline }} 4 + {{ if .ShortSummary }} 5 + {{ .Pipeline.ShortStatusSummary }} 6 {{ else }} 7 + {{ .Pipeline.LongStatusSummary }} 8 + {{ end }} 9 + </div> 10 + {{ end }} 11 12 + {{ define "symbol" }} 13 + {{ $c := .Counts }} 14 + {{ $statuses := .Statuses }} 15 + {{ $total := len $statuses }} 16 + {{ $success := index $c "success" }} 17 + {{ $fail := index $c "failed" }} 18 + {{ $timeout := index $c "timeout" }} 19 + {{ $empty := eq $total 0 }} 20 + {{ $allPass := eq $success $total }} 21 + {{ $allFail := eq $fail $total }} 22 + {{ $allTimeout := eq $timeout $total }} 23 24 + {{ if $empty }} 25 + {{ i "hourglass" "size-4 text-gray-600 dark:text-gray-400 " }} 26 + {{ else if $allPass }} 27 + {{ i "check" "size-4 text-green-600 dark:text-green-500" }} 28 + {{ else if $allFail }} 29 + {{ i "x" "size-4 text-red-600 dark:text-red-500" }} 30 + {{ else if $allTimeout }} 31 + {{ i "clock-alert" "size-4 text-orange-500" }} 32 + {{ else }} 33 + {{ $radius := f64 8 }} 34 + {{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }} 35 + {{ $offset := 0.0 }} 36 + <svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20"> 37 + <circle cx="10" cy="10" r="{{ $radius }}" fill="none" class="stroke-gray-200 dark:stroke-gray-700" stroke-width="2"/> 38 + {{ range $kind, $count := $c }} 39 + {{ $colorClass := "" }} 40 + {{ if or (eq $kind "pending") (eq $kind "running") }} 41 + {{ $colorClass = "stroke-yellow-600 dark:stroke-yellow-500" }} 42 + {{ else if eq $kind "success" }} 43 + {{ $colorClass = "stroke-green-600 dark:stroke-green-500" }} 44 + {{ else if eq $kind "cancelled" }} 45 + {{ $colorClass = "stroke-gray-600 dark:stroke-gray-500" }} 46 + {{ else if eq $kind "timeout" }} 47 + {{ $colorClass = "stroke-orange-600 dark:stroke-orange-500" }} 48 + {{ else }} 49 + {{ $colorClass = "stroke-red-600 dark:stroke-red-500" }} 50 + {{ end }} 51 + {{ $percent := divf64 (f64 $count) (f64 $total) }} 52 + {{ $length := mulf64 $percent $circumference }} 53 + <circle 54 + cx="10" cy="10" r="{{ $radius }}" 55 + fill="none" 56 + class="{{ $colorClass }}" 57 + stroke-width="2" 58 + stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}" 59 + stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}" 60 + /> 61 + {{ $offset = addf64 $offset $length }} 62 + {{ end }} 63 + </svg> 64 + {{ end }} 65 {{ end }}
+1 -1
appview/pages/templates/repo/pipelines/fragments/pipelineSymbolLong.html
··· 4 <div class="relative inline-block"> 5 <details class="relative"> 6 <summary class="cursor-pointer list-none"> 7 - {{ template "repo/pipelines/fragments/pipelineSymbol" .Pipeline }} 8 </summary> 9 {{ template "repo/pipelines/fragments/tooltip" $ }} 10 </details>
··· 4 <div class="relative inline-block"> 5 <details class="relative"> 6 <summary class="cursor-pointer list-none"> 7 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 8 </summary> 9 {{ template "repo/pipelines/fragments/tooltip" $ }} 10 </details>
+1 -1
appview/pages/templates/repo/pipelines/pipelines.html
··· 23 </p> 24 <p> 25 <span class="{{ $bullet }}">2</span>Configure your CI/CD 26 - <a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>. 27 </p> 28 <p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p> 29 </div>
··· 23 </p> 24 <p> 25 <span class="{{ $bullet }}">2</span>Configure your CI/CD 26 + <a href="https://docs.tangled.org/spindles.html#pipelines" class="underline">pipeline</a>. 27 </p> 28 <p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p> 29 </div>
+14
appview/pages/templates/repo/pipelines/workflow.html
··· 12 {{ block "sidebar" . }} {{ end }} 13 </div> 14 <div class="col-span-1 md:col-span-3"> 15 {{ block "logs" . }} {{ end }} 16 </div> 17 </section>
··· 12 {{ block "sidebar" . }} {{ end }} 13 </div> 14 <div class="col-span-1 md:col-span-3"> 15 + <!-- TODO(boltless): explictly check for pipeline cancel permission --> 16 + {{ if $.RepoInfo.Roles.IsOwner }} 17 + <div class="flex justify-between mb-2"> 18 + <div id="workflow-error" class="text-red-500 dark:text-red-400"></div> 19 + <button 20 + class="btn" 21 + hx-post="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/cancel" 22 + hx-swap="none" 23 + {{ if (index .Pipeline.Statuses .Workflow).Latest.Status.IsFinish -}} 24 + disabled 25 + {{- end }} 26 + >Cancel</button> 27 + </div> 28 + {{ end }} 29 {{ block "logs" . }} {{ end }} 30 </div> 31 </section>
+17 -17
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 24 {{ $isUpToDate := .ResubmitCheck.No }} 25 - <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative"> 26 <button 27 hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 28 hx-target="#actions-{{$roundNumber}}" 29 hx-swap="outerHtml" 30 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 - {{ i "message-square-plus" "w-4 h-4" }} 32 - <span>comment</span> 33 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 34 </button> 35 {{ if .BranchDeleteStatus }} 36 <button 37 hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 38 hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 39 hx-swap="none" 40 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 41 {{ i "git-branch" "w-4 h-4" }} 42 <span>delete branch</span> 43 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} ··· 52 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 53 hx-swap="none" 54 hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 55 - class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 56 - {{ i "git-merge" "w-4 h-4" }} 57 - <span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span> 58 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 </button> 60 {{ end }} 61 ··· 74 {{ end }} 75 76 hx-disabled-elt="#resubmitBtn" 77 - class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 78 79 {{ if $disabled }} 80 title="Update this branch to resubmit this pull request" ··· 82 title="Resubmit this pull request" 83 {{ end }} 84 > 85 - {{ i "rotate-ccw" "w-4 h-4" }} 86 - <span>resubmit</span> 87 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 </button> 89 {{ end }} 90 ··· 92 <button 93 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 94 hx-swap="none" 95 - class="btn p-2 flex items-center gap-2 group"> 96 - {{ i "ban" "w-4 h-4" }} 97 - <span>close</span> 98 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 99 </button> 100 {{ end }} 101 ··· 103 <button 104 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 105 hx-swap="none" 106 - class="btn p-2 flex items-center gap-2 group"> 107 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 108 - <span>reopen</span> 109 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 110 </button> 111 {{ end }} 112 </div>
··· 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 24 {{ $isUpToDate := .ResubmitCheck.No }} 25 + <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative p-2"> 26 <button 27 hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 28 hx-target="#actions-{{$roundNumber}}" 29 hx-swap="outerHtml" 30 + class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 + {{ i "message-square-plus" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 32 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 + comment 34 </button> 35 {{ if .BranchDeleteStatus }} 36 <button 37 hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 38 hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 39 hx-swap="none" 40 + class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 41 {{ i "git-branch" "w-4 h-4" }} 42 <span>delete branch</span> 43 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} ··· 52 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 53 hx-swap="none" 54 hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 55 + class="btn-flat p-2 flex items-center gap-2 group" {{ $disabled }}> 56 + {{ i "git-merge" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 57 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + merge{{if $stackCount}} {{$stackCount}}{{end}} 59 </button> 60 {{ end }} 61 ··· 74 {{ end }} 75 76 hx-disabled-elt="#resubmitBtn" 77 + class="btn-flat p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 78 79 {{ if $disabled }} 80 title="Update this branch to resubmit this pull request" ··· 82 title="Resubmit this pull request" 83 {{ end }} 84 > 85 + {{ i "rotate-ccw" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 86 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 87 + resubmit 88 </button> 89 {{ end }} 90 ··· 92 <button 93 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 94 hx-swap="none" 95 + class="btn-flat p-2 flex items-center gap-2 group"> 96 + {{ i "ban" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 97 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 98 + close 99 </button> 100 {{ end }} 101 ··· 103 <button 104 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 105 hx-swap="none" 106 + class="btn-flat p-2 flex items-center gap-2 group"> 107 + {{ i "refresh-ccw-dot" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 108 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 109 + reopen 110 </button> 111 {{ end }} 112 </div>
+6 -7
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 | description }} 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> ··· 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 flex flex-wrap items-center gap-1"> 30 opened by 31 {{ template "user/fragments/picHandleLink" .Pull.OwnerDid }}
··· 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 + <header class="pb-2"> 3 <h1 class="text-2xl dark:text-white"> 4 {{ .Pull.Title | description }} 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> ··· 17 {{ $icon = "git-merge" }} 18 {{ end }} 19 20 + <section> 21 <div class="flex items-center gap-2"> 22 + <span 23 + class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm" 24 > 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white" }} 26 <span class="text-white">{{ .Pull.State.String }}</span> 27 + </span> 28 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 29 opened by 30 {{ template "user/fragments/picHandleLink" .Pull.OwnerDid }}
+39 -24
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 - {{ resolve .LoggedInUser.Did }} 7 - </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 10 - hx-indicator="#create-comment-spinner" 11 hx-swap="none" 12 - class="w-full flex flex-wrap gap-2" 13 > 14 <textarea 15 name="body" 16 class="w-full p-2 rounded border border-gray-200" 17 placeholder="Add to the discussion..."></textarea 18 > 19 - <button type="submit" class="btn flex items-center gap-2"> 20 - {{ i "message-square" "w-4 h-4" }} 21 - <span>comment</span> 22 - <span id="create-comment-spinner" class="group"> 23 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 24 - </span> 25 - </button> 26 - <button 27 - type="button" 28 - class="btn flex items-center gap-2 group" 29 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 30 - hx-swap="outerHTML" 31 - hx-target="#pull-comment-card-{{ .RoundNumber }}" 32 - > 33 - {{ i "x" "w-4 h-4" }} 34 - <span>cancel</span> 35 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 - </button> 37 <div id="pull-comment"></div> 38 </form> 39 </div> 40 {{ end }}
··· 1 {{ define "repo/pulls/fragments/pullNewComment" }} 2 <div 3 id="pull-comment-card-{{ .RoundNumber }}" 4 + class="w-full flex flex-col gap-2"> 5 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 6 <form 7 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 8 hx-swap="none" 9 + hx-on::after-request="if(event.detail.successful) this.reset()" 10 + hx-disabled-elt="#reply-{{ .RoundNumber }}" 11 + class="w-full flex flex-wrap gap-2 group" 12 > 13 <textarea 14 name="body" 15 class="w-full p-2 rounded border border-gray-200" 16 + rows=8 17 placeholder="Add to the discussion..."></textarea 18 > 19 + {{ template "replyActions" . }} 20 <div id="pull-comment"></div> 21 </form> 22 </div> 23 {{ end }} 24 + 25 + {{ define "replyActions" }} 26 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm w-full"> 27 + {{ template "cancel" . }} 28 + {{ template "reply" . }} 29 + </div> 30 + {{ end }} 31 + 32 + {{ define "cancel" }} 33 + <button 34 + type="button" 35 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 36 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 37 + hx-swap="outerHTML" 38 + hx-target="#actions-{{.RoundNumber}}" 39 + > 40 + {{ i "x" "w-4 h-4" }} 41 + <span>cancel</span> 42 + </button> 43 + {{ end }} 44 + 45 + {{ define "reply" }} 46 + <button 47 + type="submit" 48 + id="reply-{{ .RoundNumber }}" 49 + class="btn-create flex items-center gap-2"> 50 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 51 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + reply 53 + </button> 54 + {{ end }} 55 +
+2 -3
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 15 16 <div class="flex-shrink-0 flex items-center gap-2"> 17 {{ $latestRound := .LastRoundNumber }} 18 - {{ $lastSubmission := index .Submissions $latestRound }} 19 - {{ $commentCount := len $lastSubmission.Comments }} 20 {{ if and $pipeline $pipeline.Id }} 21 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 22 <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 23 {{ end }} 24 <span>
··· 15 16 <div class="flex-shrink-0 flex items-center gap-2"> 17 {{ $latestRound := .LastRoundNumber }} 18 + {{ $commentCount := .TotalComments }} 19 {{ if and $pipeline $pipeline.Id }} 20 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 21 <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 22 {{ end }} 23 <span>
+2 -21
appview/pages/templates/repo/pulls/interdiff.html
··· 25 {{ template "repo/pulls/fragments/pullHeader" . }} 26 </header> 27 </section> 28 - 29 {{ end }} 30 31 {{ define "mainLayout" }} ··· 34 {{ block "content" . }}{{ end }} 35 {{ end }} 36 37 - {{ block "contentAfterLayout" . }} 38 - <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 39 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 40 - {{ block "contentAfterLeft" . }} {{ end }} 41 - </div> 42 - <main class="col-span-1 md:col-span-10"> 43 - {{ block "contentAfter" . }}{{ end }} 44 - </main> 45 - </div> 46 - {{ end }} 47 </div> 48 {{ end }} 49 50 {{ define "contentAfter" }} 51 - {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }} 52 - {{end}} 53 - 54 - {{ define "contentAfterLeft" }} 55 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 56 - {{ template "repo/fragments/diffOpts" .DiffOpts }} 57 - </div> 58 - <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 59 - {{ template "repo/fragments/interdiffFiles" .Interdiff }} 60 - </div> 61 {{end}}
··· 25 {{ template "repo/pulls/fragments/pullHeader" . }} 26 </header> 27 </section> 28 {{ end }} 29 30 {{ define "mainLayout" }} ··· 33 {{ block "content" . }}{{ end }} 34 {{ end }} 35 36 + {{ block "contentAfter" . }}{{ end }} 37 </div> 38 {{ end }} 39 40 {{ define "contentAfter" }} 41 + {{ template "repo/fragments/diff" (list .Interdiff .DiffOpts) }} 42 {{end}}
+448 -232
appview/pages/templates/repo/pulls/pull.html
··· 6 {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 7 {{ end }} 8 9 {{ define "repoContentLayout" }} 10 - <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 11 - <div class="col-span-1 md:col-span-8"> 12 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 13 - {{ block "repoContent" . }}{{ end }} 14 - </section> 15 - {{ block "repoAfter" . }}{{ end }} 16 - </div> 17 - <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 18 {{ template "repo/fragments/labelPanel" 19 (dict "RepoInfo" $.RepoInfo 20 "Defs" $.LabelDefs ··· 29 </div> 30 {{ end }} 31 32 {{ define "repoContent" }} 33 {{ template "repo/pulls/fragments/pullHeader" . }} 34 - 35 {{ if .Pull.IsStacked }} 36 <div class="mt-8"> 37 {{ template "repo/pulls/fragments/pullStack" . }} ··· 39 {{ end }} 40 {{ end }} 41 42 - {{ define "repoAfter" }} 43 - <section id="submissions" class="mt-4"> 44 - <div class="flex flex-col gap-4"> 45 - {{ block "submissions" . }} {{ end }} 46 </div> 47 - </section> 48 49 - <div id="pull-close"></div> 50 - <div id="pull-reopen"></div> 51 {{ end }} 52 53 {{ define "submissions" }} 54 {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 55 - {{ $targetBranch := .Pull.TargetBranch }} 56 - {{ $repoName := .RepoInfo.FullName }} 57 - {{ range $idx, $item := .Pull.Submissions }} 58 - {{ with $item }} 59 - <details {{ if eq $idx $lastIdx }}open{{ end }}> 60 - <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 61 - <div class="flex flex-wrap gap-2 items-stretch"> 62 - <!-- round number --> 63 - <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 64 - <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 65 - </div> 66 - <!-- round summary --> 67 - <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 68 - <span class="gap-1 flex items-center"> 69 - {{ $owner := resolve $.Pull.OwnerDid }} 70 - {{ $re := "re" }} 71 - {{ if eq .RoundNumber 0 }} 72 - {{ $re = "" }} 73 - {{ end }} 74 - <span class="hidden md:inline">{{$re}}submitted</span> 75 - by {{ template "user/fragments/picHandleLink" $.Pull.OwnerDid }} 76 - <span class="select-none before:content-['\00B7']"></span> 77 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a> 78 - <span class="select-none before:content-['ยท']"></span> 79 - {{ $s := "s" }} 80 - {{ if eq (len .Comments) 1 }} 81 - {{ $s = "" }} 82 - {{ end }} 83 - {{ len .Comments }} comment{{$s}} 84 - </span> 85 - </div> 86 87 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 88 - hx-boost="true" 89 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 90 - {{ i "file-diff" "w-4 h-4" }} 91 - <span class="hidden md:inline">diff</span> 92 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 93 - </a> 94 - {{ if ne $idx 0 }} 95 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 96 - hx-boost="true" 97 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 98 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 99 - <span class="hidden md:inline">interdiff</span> 100 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 101 - </a> 102 - {{ end }} 103 - <span id="interdiff-error-{{.RoundNumber}}"></span> 104 - </div> 105 - </summary> 106 107 - {{ if .IsFormatPatch }} 108 - {{ $patches := .AsFormatPatch }} 109 - {{ $round := .RoundNumber }} 110 - <details class="group py-2 md:ml-[3.5rem] text-gray-500 dark:text-gray-400 flex flex-col gap-2 relative text-sm"> 111 - <summary class="py-1 list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 112 - {{ $s := "s" }} 113 - {{ if eq (len $patches) 1 }} 114 - {{ $s = "" }} 115 - {{ end }} 116 - <div class="group-open:hidden flex items-center gap-2 ml-2"> 117 - {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $patches }} commit{{$s}} 118 - </div> 119 - <div class="hidden group-open:flex items-center gap-2 ml-2"> 120 - {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $patches }} commit{{$s}} 121 - </div> 122 - </summary> 123 - {{ range $patches }} 124 - <div id="commit-{{.SHA}}" class="py-1 px-2 relative w-full md:max-w-3/5 md:w-fit flex flex-col"> 125 - <div class="flex items-center gap-2"> 126 - {{ i "git-commit-horizontal" "w-4 h-4" }} 127 - <div class="text-sm text-gray-500 dark:text-gray-400"> 128 - <!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches --> 129 - {{ $fullRepo := "" }} 130 - {{ if and $.Pull.IsForkBased $.Pull.PullSource.Repo }} 131 - {{ $fullRepo = printf "%s/%s" $owner $.Pull.PullSource.Repo.Name }} 132 - {{ else if $.Pull.IsBranchBased }} 133 - {{ $fullRepo = $.RepoInfo.FullName }} 134 - {{ end }} 135 136 - <!-- if $fullRepo was resolved, link to it, otherwise just span without a link --> 137 - {{ if $fullRepo }} 138 - <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a> 139 - {{ else }} 140 - <span class="font-mono">{{ slice .SHA 0 8 }}</span> 141 - {{ end }} 142 - </div> 143 - <div class="flex items-center"> 144 - <span>{{ .Title | description }}</span> 145 - {{ if gt (len .Body) 0 }} 146 - <button 147 - 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" 148 - hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 149 - > 150 - {{ i "ellipsis" "w-3 h-3" }} 151 - </button> 152 - {{ end }} 153 - </div> 154 - </div> 155 - {{ if gt (len .Body) 0 }} 156 - <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2"> 157 - {{ nl2br .Body }} 158 - </p> 159 - {{ end }} 160 - </div> 161 - {{ end }} 162 - </details> 163 - {{ end }} 164 165 166 - <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 167 - {{ range $cidx, $c := .Comments }} 168 - <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 169 - {{ if gt $cidx 0 }} 170 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 171 - {{ end }} 172 - <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 173 - {{ template "user/fragments/picHandleLink" $c.OwnerDid }} 174 - <span class="before:content-['ยท']"></span> 175 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 176 - </div> 177 - <div class="prose dark:prose-invert"> 178 - {{ $c.Body | markdown }} 179 - </div> 180 - </div> 181 - {{ end }} 182 183 - {{ block "pipelineStatus" (list $ .) }} {{ end }} 184 185 - {{ if eq $lastIdx .RoundNumber }} 186 - {{ block "mergeStatus" $ }} {{ end }} 187 - {{ block "resubmitStatus" $ }} {{ end }} 188 {{ end }} 189 190 - {{ if $.LoggedInUser }} 191 - {{ template "repo/pulls/fragments/pullActions" 192 - (dict 193 - "LoggedInUser" $.LoggedInUser 194 - "Pull" $.Pull 195 - "RepoInfo" $.RepoInfo 196 - "RoundNumber" .RoundNumber 197 - "MergeCheck" $.MergeCheck 198 - "ResubmitCheck" $.ResubmitCheck 199 - "BranchDeleteStatus" $.BranchDeleteStatus 200 - "Stack" $.Stack) }} 201 {{ else }} 202 - <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center w-fit"> 203 - <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 204 - sign up 205 - </a> 206 - <span class="text-gray-500 dark:text-gray-400">or</span> 207 - <a href="/login" class="underline">login</a> 208 - to add to the discussion 209 - </div> 210 {{ end }} 211 </div> 212 </details> 213 - {{ end }} 214 {{ end }} 215 {{ end }} 216 217 {{ define "mergeStatus" }} 218 {{ if .Pull.State.IsClosed }} 219 - <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"> 220 <div class="flex items-center gap-2 text-black dark:text-white"> 221 {{ i "ban" "w-4 h-4" }} 222 <span class="font-medium">closed without merging</span ··· 224 </div> 225 </div> 226 {{ else if .Pull.State.IsMerged }} 227 - <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 228 <div class="flex items-center gap-2 text-purple-500 dark:text-purple-300"> 229 {{ i "git-merge" "w-4 h-4" }} 230 <span class="font-medium">pull request successfully merged</span ··· 232 </div> 233 </div> 234 {{ else if .Pull.State.IsDeleted }} 235 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 236 <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 237 {{ i "git-pull-request-closed" "w-4 h-4" }} 238 <span class="font-medium">This pull has been deleted (possibly by jj abandon or jj squash)</span> 239 </div> 240 </div> 241 - {{ else if and .MergeCheck .MergeCheck.Error }} 242 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 243 - <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 244 - {{ i "triangle-alert" "w-4 h-4" }} 245 - <span class="font-medium">{{ .MergeCheck.Error }}</span> 246 - </div> 247 - </div> 248 - {{ else if and .MergeCheck .MergeCheck.IsConflicted }} 249 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 250 - <div class="flex flex-col gap-2 text-red-500 dark:text-red-300"> 251 - <div class="flex items-center gap-2"> 252 - {{ i "triangle-alert" "w-4 h-4" }} 253 - <span class="font-medium">merge conflicts detected</span> 254 - </div> 255 - {{ if gt (len .MergeCheck.Conflicts) 0 }} 256 - <ul class="space-y-1"> 257 - {{ range .MergeCheck.Conflicts }} 258 - {{ if .Filename }} 259 - <li class="flex items-center"> 260 - {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }} 261 - <span class="font-mono">{{ .Filename }}</span> 262 - </li> 263 - {{ else if .Reason }} 264 - <li class="flex items-center"> 265 - {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }} 266 - <span>{{.Reason}}</span> 267 - </li> 268 - {{ end }} 269 - {{ end }} 270 - </ul> 271 - {{ end }} 272 - </div> 273 - </div> 274 - {{ else if .MergeCheck }} 275 - <div class="bg-green-50 dark:bg-green-900 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 276 - <div class="flex items-center gap-2 text-green-500 dark:text-green-300"> 277 - {{ i "circle-check-big" "w-4 h-4" }} 278 - <span class="font-medium">no conflicts, ready to merge</span> 279 - </div> 280 - </div> 281 {{ end }} 282 {{ end }} 283 284 {{ define "resubmitStatus" }} 285 {{ if .ResubmitCheck.Yes }} 286 - <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 287 <div class="flex items-center gap-2 text-amber-500 dark:text-amber-300"> 288 {{ i "triangle-alert" "w-4 h-4" }} 289 <span class="font-medium">this branch has been updated, consider resubmitting</span> ··· 292 {{ end }} 293 {{ end }} 294 295 - {{ define "pipelineStatus" }} 296 - {{ $root := index . 0 }} 297 - {{ $submission := index . 1 }} 298 - {{ $pipeline := index $root.Pipelines $submission.SourceRev }} 299 {{ with $pipeline }} 300 {{ $id := .Id }} 301 {{ if .Statuses }} 302 - <div class="max-w-80 grid grid-cols-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 303 - {{ range $name, $all := .Statuses }} 304 - <a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 305 - <div 306 - class="flex gap-2 items-center justify-between p-2"> 307 - {{ $lastStatus := $all.Latest }} 308 - {{ $kind := $lastStatus.Status.String }} 309 310 - <div id="left" class="flex items-center gap-2 flex-shrink-0"> 311 - {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 312 - {{ $name }} 313 - </div> 314 - <div id="right" class="flex items-center gap-2 flex-shrink-0"> 315 - <span class="font-bold">{{ $kind }}</span> 316 - {{ if .TimeTaken }} 317 - {{ template "repo/fragments/duration" .TimeTaken }} 318 - {{ else }} 319 - {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 320 - {{ end }} 321 - </div> 322 </div> 323 </a> 324 - {{ end }} 325 </div> 326 - {{ end }} 327 - {{ end }} 328 {{ end }}
··· 6 {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 7 {{ end }} 8 9 + {{ define "mainLayout" }} 10 + <div class="px-1 flex-grow flex flex-col gap-4"> 11 + <div class="max-w-screen-lg mx-auto"> 12 + {{ block "contentLayout" . }} 13 + {{ block "content" . }}{{ end }} 14 + {{ end }} 15 + </div> 16 + {{ block "contentAfterLayout" . }} 17 + <main> 18 + {{ block "contentAfter" . }}{{ end }} 19 + </main> 20 + {{ end }} 21 + </div> 22 + <script> 23 + (function() { 24 + const details = document.getElementById('bottomSheet'); 25 + const isDesktop = () => window.matchMedia('(min-width: 768px)').matches; 26 + 27 + // close on mobile initially 28 + if (!isDesktop()) { 29 + details.open = false; 30 + } 31 + 32 + // prevent closing on desktop 33 + details.addEventListener('toggle', function(e) { 34 + if (isDesktop() && !this.open) { 35 + this.open = true; 36 + } 37 + }); 38 + 39 + const mediaQuery = window.matchMedia('(min-width: 768px)'); 40 + mediaQuery.addEventListener('change', function(e) { 41 + if (e.matches) { 42 + // switched to desktop - keep open 43 + details.open = true; 44 + } else { 45 + // switched to mobile - close 46 + details.open = false; 47 + } 48 + }); 49 + })(); 50 + </script> 51 + {{ end }} 52 + 53 {{ define "repoContentLayout" }} 54 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4"> 55 + <section class="bg-white col-span-1 md:col-span-8 dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white h-full flex-shrink"> 56 + {{ block "repoContent" . }}{{ end }} 57 + </section> 58 + <div class="flex flex-col gap-6 col-span-1 md:col-span-2"> 59 {{ template "repo/fragments/labelPanel" 60 (dict "RepoInfo" $.RepoInfo 61 "Defs" $.LabelDefs ··· 70 </div> 71 {{ end }} 72 73 + {{ define "contentAfter" }} 74 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts $) }} 75 + {{ end }} 76 + 77 {{ define "repoContent" }} 78 {{ template "repo/pulls/fragments/pullHeader" . }} 79 {{ if .Pull.IsStacked }} 80 <div class="mt-8"> 81 {{ template "repo/pulls/fragments/pullStack" . }} ··· 83 {{ end }} 84 {{ end }} 85 86 + {{ define "diffLayout" }} 87 + {{ $diff := index . 0 }} 88 + {{ $opts := index . 1 }} 89 + {{ $root := index . 2 }} 90 + 91 + <div class="flex col-span-full"> 92 + <!-- left panel --> 93 + <div id="files" class="w-0 hidden md:block overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 94 + <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 95 + {{ template "repo/fragments/fileTree" $diff.FileTree }} 96 + </section> 97 + </div> 98 + 99 + <!-- main content --> 100 + <div class="flex-1 min-w-0 sticky top-12 pb-12"> 101 + {{ template "diffFiles" (list $diff $opts) }} 102 + </div> 103 + 104 + <!-- right panel --> 105 + {{ template "subsPanel" $ }} 106 + </div> 107 + {{ end }} 108 + 109 + {{ define "subsPanel" }} 110 + {{ $root := index . 2 }} 111 + {{ $pull := $root.Pull }} 112 + 113 + <!-- backdrop overlay - only visible on mobile when open --> 114 + <div class=" 115 + fixed inset-0 bg-black/50 z-50 md:hidden opacity-0 116 + pointer-events-none transition-opacity duration-300 117 + has-[~#subs_details[open]]:opacity-100 has-[~#subs_details[open]]:pointer-events-auto"> 118 + </div> 119 + <!-- right panel - bottom sheet on mobile, side panel on desktop --> 120 + <div id="subs" class="fixed bottom-0 left-0 right-0 z-50 w-full md:static md:z-auto md:max-h-screen md:sticky md:top-12 overflow-hidden"> 121 + <details open id="bottomSheet" class="group rounded-t-2xl md:rounded-t-sm drop-shadow-lg md:drop-shadow-none"> 122 + <summary class=" 123 + flex gap-4 items-center justify-between 124 + rounded-t-2xl md:rounded-t-sm cursor-pointer list-none p-4 md:h-12 125 + text-white md:text-black md:dark:text-white 126 + bg-green-600 dark:bg-green-600 127 + md:bg-white md:dark:bg-gray-800 128 + drop-shadow-sm 129 + md:border-b md:border-x border-gray-200 dark:border-gray-700"> 130 + <h2 class="">Review Panel </h2> 131 + {{ template "subsPanelSummary" $ }} 132 + </summary> 133 + <div class="max-h-[85vh] md:max-h-[calc(100vh-3rem-3rem)] w-full flex flex-col-reverse gap-4 overflow-y-auto bg-slate-100 dark:bg-gray-900 md:bg-transparent"> 134 + {{ template "submissions" $root }} 135 </div> 136 + </details> 137 + </div> 138 + {{ end }} 139 + 140 + {{ define "subsPanelSummary" }} 141 + {{ $root := index . 2 }} 142 + {{ $pull := $root.Pull }} 143 + {{ $latest := $pull.LastRoundNumber }} 144 + <div class="flex items-center gap-2 text-sm"> 145 + {{ if $root.IsInterdiff }} 146 + <span> 147 + viewing interdiff of 148 + <span class="font-mono">#{{ $root.ActiveRound }}</span> 149 + and 150 + <span class="font-mono">#{{ sub $root.ActiveRound 1 }}</span> 151 + </span> 152 + {{ else }} 153 + <span> 154 + viewing round 155 + <span class="font-mono">#{{ $root.ActiveRound }}</span> 156 + </span> 157 + {{ if ne $root.ActiveRound $latest }} 158 + <span>(outdated)</span> 159 + <span class="before:content-['ยท']"></span> 160 + <a class="underline" href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $latest }}?{{ safeUrl $root.DiffOpts.Encode }}"> 161 + view latest 162 + </a> 163 + {{ end }} 164 + {{ end }} 165 + <span class="md:hidden inline"> 166 + <span class="inline group-open:hidden">{{ i "chevron-up" "size-4" }}</span> 167 + <span class="hidden group-open:inline">{{ i "chevron-down" "size-4" }}</span> 168 + </span> 169 + </div> 170 + {{ end }} 171 172 + {{ define "subsCheckbox" }} 173 + <input type="checkbox" id="subsToggle" class="peer/subs hidden" checked/> 174 {{ end }} 175 176 + {{ define "subsToggle" }} 177 + <style> 178 + /* Mobile: full width */ 179 + #subsToggle:checked ~ div div#subs { 180 + width: 100%; 181 + margin-left: 0; 182 + } 183 + #subsToggle:checked ~ div label[for="subsToggle"] .show-toggle { display: none; } 184 + #subsToggle:checked ~ div label[for="subsToggle"] .hide-toggle { display: flex; } 185 + #subsToggle:not(:checked) ~ div label[for="subsToggle"] .hide-toggle { display: none; } 186 + 187 + /* Desktop: 25vw with left margin */ 188 + @media (min-width: 768px) { 189 + #subsToggle:checked ~ div div#subs { 190 + width: 25vw; 191 + margin-left: 1rem; 192 + } 193 + /* Unchecked state */ 194 + #subsToggle:not(:checked) ~ div div#subs { 195 + width: 0; 196 + display: none; 197 + margin-left: 0; 198 + } 199 + } 200 + </style> 201 + <label title="Toggle review panel" for="subsToggle" class="hidden md:flex items-center justify-end rounded cursor-pointer"> 202 + <span class="show-toggle">{{ i "message-square-more" "size-4" }}</span> 203 + <span class="hide-toggle w-[25vw] flex justify-end">{{ i "message-square" "size-4" }}</span> 204 + </label> 205 + {{ end }} 206 + 207 + 208 {{ define "submissions" }} 209 {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 210 + {{ range $ridx, $item := reverse .Pull.Submissions }} 211 + {{ $idx := sub $lastIdx $ridx }} 212 + {{ template "submission" (list $item $idx $lastIdx $) }} 213 + {{ end }} 214 + {{ end }} 215 216 + {{ define "submission" }} 217 + {{ $item := index . 0 }} 218 + {{ $idx := index . 1 }} 219 + {{ $lastIdx := index . 2 }} 220 + {{ $root := index . 3 }} 221 + <div class="rounded border border-gray-200 dark:border-gray-700 w-full shadow-sm bg-gray-50 dark:bg-gray-800/50"> 222 + {{ template "submissionHeader" $ }} 223 + {{ template "submissionComments" $ }} 224 225 + {{ if eq $lastIdx $item.RoundNumber }} 226 + {{ block "mergeStatus" $root }} {{ end }} 227 + {{ block "resubmitStatus" $root }} {{ end }} 228 + {{ end }} 229 230 + {{ if $root.LoggedInUser }} 231 + {{ template "repo/pulls/fragments/pullActions" 232 + (dict 233 + "LoggedInUser" $root.LoggedInUser 234 + "Pull" $root.Pull 235 + "RepoInfo" $root.RepoInfo 236 + "RoundNumber" $item.RoundNumber 237 + "MergeCheck" $root.MergeCheck 238 + "ResubmitCheck" $root.ResubmitCheck 239 + "BranchDeleteStatus" $root.BranchDeleteStatus 240 + "Stack" $root.Stack) }} 241 + {{ else }} 242 + {{ template "loginPrompt" $ }} 243 + {{ end }} 244 + </div> 245 + {{ end }} 246 247 + {{ define "submissionHeader" }} 248 + {{ $item := index . 0 }} 249 + {{ $lastIdx := index . 2 }} 250 + {{ $root := index . 3 }} 251 + {{ $round := $item.RoundNumber }} 252 + <div class="rounded px-6 py-4 pr-2 pt-2 bg-white dark:bg-gray-800 flex gap-2 sticky top-0 z-20 border-b border-gray-200 dark:border-gray-700"> 253 + <!-- left column: just profile picture --> 254 + <div class="flex-shrink-0 pt-2"> 255 + <img 256 + src="{{ tinyAvatar $root.Pull.OwnerDid }}" 257 + alt="" 258 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 259 + /> 260 + </div> 261 + <!-- right column --> 262 + <div class="flex-1 min-w-0 flex flex-col gap-1"> 263 + {{ template "submissionInfo" $ }} 264 + {{ template "submissionCommits" $ }} 265 + {{ template "submissionPipeline" $ }} 266 + {{ if eq $lastIdx $round }} 267 + {{ block "mergeCheck" $root }} {{ end }} 268 + {{ end }} 269 + </div> 270 + </div> 271 + {{ end }} 272 273 + {{ define "submissionInfo" }} 274 + {{ $item := index . 0 }} 275 + {{ $idx := index . 1 }} 276 + {{ $root := index . 3 }} 277 + {{ $round := $item.RoundNumber }} 278 + <div class="flex gap-2 items-center justify-between mb-1"> 279 + <span class="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 pt-2"> 280 + {{ resolve $root.Pull.OwnerDid }} submitted v{{ $round }} 281 + <span class="select-none before:content-['\00B7']"></span> 282 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ $round }}"> 283 + {{ template "repo/fragments/shortTimeAgo" $item.Created }} 284 + </a> 285 + </span> 286 + <div class="flex gap-2 items-center"> 287 + {{ if ne $root.ActiveRound $round }} 288 + <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 289 + href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}?{{ safeUrl $root.DiffOpts.Encode }}"> 290 + {{ i "diff" "w-4 h-4" }} 291 + diff 292 + </a> 293 + {{ end }} 294 + {{ if ne $idx 0 }} 295 + <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 296 + href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}/interdiff?{{ safeUrl $root.DiffOpts.Encode }}"> 297 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 298 + interdiff 299 + </a> 300 + {{ end }} 301 + </div> 302 + </div> 303 + {{ end }} 304 305 + {{ define "submissionCommits" }} 306 + {{ $item := index . 0 }} 307 + {{ $root := index . 3 }} 308 + {{ $round := $item.RoundNumber }} 309 + {{ $patches := $item.AsFormatPatch }} 310 + {{ if $patches }} 311 + <details class="group/commit"> 312 + <summary class="list-none cursor-pointer flex items-center gap-2"> 313 + <span>{{ i "git-commit-horizontal" "w-4 h-4" }}</span> 314 + {{ len $patches }} commit{{ if ne (len $patches) 1 }}s{{ end }} 315 + <div class="text-sm text-gray-500 dark:text-gray-400"> 316 + <span class="group-open/commit:hidden inline">expand</span> 317 + <span class="hidden group-open/commit:inline">collapse</span> 318 + </div> 319 + </summary> 320 + {{ range $patches }} 321 + {{ template "submissionCommit" (list . $item $root) }} 322 + {{ end }} 323 + </details> 324 + {{ end }} 325 + {{ end }} 326 327 + {{ define "submissionCommit" }} 328 + {{ $patch := index . 0 }} 329 + {{ $item := index . 1 }} 330 + {{ $root := index . 2 }} 331 + {{ $round := $item.RoundNumber }} 332 + {{ with $patch }} 333 + <div id="commit-{{.SHA}}" class="py-1 relative w-full md:max-w-3/5 md:w-fit flex flex-col text-gray-600 dark:text-gray-300"> 334 + <div class="flex items-baseline gap-2"> 335 + <div class="text-xs"> 336 + <!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches --> 337 + {{ $fullRepo := "" }} 338 + {{ if and $root.Pull.IsForkBased $root.Pull.PullSource.Repo }} 339 + {{ $fullRepo = printf "%s/%s" $root.Pull.OwnerDid $root.Pull.PullSource.Repo.Name }} 340 + {{ else if $root.Pull.IsBranchBased }} 341 + {{ $fullRepo = $root.RepoInfo.FullName }} 342 {{ end }} 343 344 + <!-- if $fullRepo was resolved, link to it, otherwise just span without a link --> 345 + {{ if $fullRepo }} 346 + <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-600 dark:text-gray-300">{{ slice .SHA 0 8 }}</a> 347 {{ else }} 348 + <span class="font-mono">{{ slice .SHA 0 8 }}</span> 349 + {{ end }} 350 + </div> 351 + 352 + <div> 353 + <span>{{ .Title | description }}</span> 354 + {{ if gt (len .Body) 0 }} 355 + <button 356 + 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" 357 + hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 358 + > 359 + {{ i "ellipsis" "w-3 h-3" }} 360 + </button> 361 + {{ end }} 362 + {{ if gt (len .Body) 0 }} 363 + <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 pb-2">{{ nl2br .Body }}</p> 364 {{ end }} 365 </div> 366 + </div> 367 + </div> 368 + {{ end }} 369 + {{ end }} 370 + 371 + {{ define "mergeCheck" }} 372 + {{ $isOpen := .Pull.State.IsOpen }} 373 + {{ if and $isOpen .MergeCheck .MergeCheck.Error }} 374 + <div class="flex items-center gap-2"> 375 + {{ i "triangle-alert" "w-4 h-4 text-red-600 dark:text-red-500" }} 376 + {{ .MergeCheck.Error }} 377 + </div> 378 + {{ else if and $isOpen .MergeCheck .MergeCheck.IsConflicted }} 379 + <details class="group/conflict"> 380 + <summary class="flex items-center justify-between cursor-pointer list-none"> 381 + <div class="flex items-center gap-2 "> 382 + {{ i "triangle-alert" "text-red-600 dark:text-red-500 w-4 h-4" }} 383 + <span class="font-medium">merge conflicts detected</span> 384 + <div class="text-sm text-gray-500 dark:text-gray-400"> 385 + <span class="group-open/conflict:hidden inline">expand</span> 386 + <span class="hidden group-open/conflict:inline">collapse</span> 387 + </div> 388 + </div> 389 + </summary> 390 + {{ if gt (len .MergeCheck.Conflicts) 0 }} 391 + <ul class="space-y-1 mt-2 overflow-x-auto"> 392 + {{ range .MergeCheck.Conflicts }} 393 + {{ if .Filename }} 394 + <li class="flex items-center whitespace-nowrap"> 395 + {{ i "file-warning" "inline-flex w-4 h-4 mr-1.5 text-red-600 dark:text-red-500 flex-shrink-0" }} 396 + <span class="font-mono">{{ .Filename }}</span> 397 + </li> 398 + {{ else if .Reason }} 399 + <li class="flex items-center whitespace-nowrap"> 400 + {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-600 dark:text-red-500 " }} 401 + <span>{{.Reason}}</span> 402 + </li> 403 + {{ end }} 404 + {{ end }} 405 + </ul> 406 + {{ end }} 407 </details> 408 + {{ else if and $isOpen .MergeCheck }} 409 + <div class="flex items-center gap-2"> 410 + {{ i "check" "w-4 h-4 text-green-600 dark:text-green-500" }} 411 + <span>no conflicts, ready to merge</span> 412 + </div> 413 {{ end }} 414 {{ end }} 415 416 {{ define "mergeStatus" }} 417 {{ if .Pull.State.IsClosed }} 418 + <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"> 419 <div class="flex items-center gap-2 text-black dark:text-white"> 420 {{ i "ban" "w-4 h-4" }} 421 <span class="font-medium">closed without merging</span ··· 423 </div> 424 </div> 425 {{ else if .Pull.State.IsMerged }} 426 + <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative"> 427 <div class="flex items-center gap-2 text-purple-500 dark:text-purple-300"> 428 {{ i "git-merge" "w-4 h-4" }} 429 <span class="font-medium">pull request successfully merged</span ··· 431 </div> 432 </div> 433 {{ else if .Pull.State.IsDeleted }} 434 + <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative"> 435 <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 436 {{ i "git-pull-request-closed" "w-4 h-4" }} 437 <span class="font-medium">This pull has been deleted (possibly by jj abandon or jj squash)</span> 438 </div> 439 </div> 440 {{ end }} 441 {{ end }} 442 443 {{ define "resubmitStatus" }} 444 {{ if .ResubmitCheck.Yes }} 445 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative"> 446 <div class="flex items-center gap-2 text-amber-500 dark:text-amber-300"> 447 {{ i "triangle-alert" "w-4 h-4" }} 448 <span class="font-medium">this branch has been updated, consider resubmitting</span> ··· 451 {{ end }} 452 {{ end }} 453 454 + {{ define "submissionPipeline" }} 455 + {{ $item := index . 0 }} 456 + {{ $root := index . 3 }} 457 + {{ $pipeline := index $root.Pipelines $item.SourceRev }} 458 {{ with $pipeline }} 459 {{ $id := .Id }} 460 {{ if .Statuses }} 461 + <details class="group/pipeline"> 462 + <summary class="cursor-pointer list-none flex items-center gap-2"> 463 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" false) }} 464 + <div class="text-sm text-gray-500 dark:text-gray-400"> 465 + <span class="group-open/pipeline:hidden inline">expand</span> 466 + <span class="hidden group-open/pipeline:inline">collapse</span> 467 + </div> 468 + </summary> 469 + <div class="my-2 grid grid-cols-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 470 + {{ range $name, $all := .Statuses }} 471 + <a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 472 + <div 473 + class="flex gap-2 items-center justify-between p-2"> 474 + {{ $lastStatus := $all.Latest }} 475 + {{ $kind := $lastStatus.Status.String }} 476 477 + <div id="left" class="flex items-center gap-2 flex-shrink-0"> 478 + {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 479 + {{ $name }} 480 + </div> 481 + <div id="right" class="flex items-center gap-2 flex-shrink-0"> 482 + <span class="font-bold">{{ $kind }}</span> 483 + {{ if .TimeTaken }} 484 + {{ template "repo/fragments/duration" .TimeTaken }} 485 + {{ else }} 486 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 487 + {{ end }} 488 + </div> 489 + </div> 490 + </a> 491 + {{ end }} 492 </div> 493 + </details> 494 + {{ end }} 495 + {{ end }} 496 + {{ end }} 497 + 498 + {{ define "submissionComments" }} 499 + {{ $item := index . 0 }} 500 + <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 501 + {{ range $item.Comments }} 502 + {{ template "submissionComment" . }} 503 + {{ end }} 504 + </div> 505 + {{ end }} 506 + 507 + {{ define "submissionComment" }} 508 + <div id="comment-{{.ID}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto"> 509 + <!-- left column: profile picture --> 510 + <div class="flex-shrink-0"> 511 + <img 512 + src="{{ tinyAvatar .OwnerDid }}" 513 + alt="" 514 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 515 + /> 516 + </div> 517 + <!-- right column: name and body in two rows --> 518 + <div class="flex-1 min-w-0"> 519 + <!-- Row 1: Author and timestamp --> 520 + <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 521 + <span>{{ resolve .OwnerDid }}</span> 522 + <span class="before:content-['ยท']"></span> 523 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"> 524 + {{ template "repo/fragments/time" .Created }} 525 </a> 526 + </div> 527 + <!-- Row 2: Body text --> 528 + <div class="prose dark:prose-invert mt-1"> 529 + {{ .Body | markdown }} 530 </div> 531 + </div> 532 + </div> 533 + {{ end }} 534 + 535 + {{ define "loginPrompt" }} 536 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center"> 537 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 538 + sign up 539 + </a> 540 + <span class="text-gray-500 dark:text-gray-400">or</span> 541 + <a href="/login" class="underline">login</a> 542 + to add to the discussion 543 + </div> 544 {{ end }}
+11 -12
appview/pages/templates/repo/pulls/pulls.html
··· 112 {{ template "repo/fragments/time" .Created }} 113 </span> 114 115 - 116 - {{ $latestRound := .LastRoundNumber }} 117 - {{ $lastSubmission := index .Submissions $latestRound }} 118 - 119 <span class="before:content-['ยท']"> 120 - {{ $commentCount := len $lastSubmission.Comments }} 121 - {{ $s := "s" }} 122 - {{ if eq $commentCount 1 }} 123 - {{ $s = "" }} 124 - {{ end }} 125 - 126 - {{ len $lastSubmission.Comments}} comment{{$s}} 127 </span> 128 129 <span class="before:content-['ยท']"> ··· 136 {{ $pipeline := index $.Pipelines .LatestSha }} 137 {{ if and $pipeline $pipeline.Id }} 138 <span class="before:content-['ยท']"></span> 139 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 140 {{ end }} 141 142 {{ $state := .Labels }} ··· 170 </div> 171 {{ end }} 172 </div> 173 {{ end }} 174 175 {{ define "stackedPullList" }}
··· 112 {{ template "repo/fragments/time" .Created }} 113 </span> 114 115 <span class="before:content-['ยท']"> 116 + {{ $commentCount := .TotalComments }} 117 + {{ $commentCount }} comment{{ if ne $commentCount 1 }}s{{ end }} 118 </span> 119 120 <span class="before:content-['ยท']"> ··· 127 {{ $pipeline := index $.Pipelines .LatestSha }} 128 {{ if and $pipeline $pipeline.Id }} 129 <span class="before:content-['ยท']"></span> 130 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 131 {{ end }} 132 133 {{ $state := .Labels }} ··· 161 </div> 162 {{ end }} 163 </div> 164 + {{if gt .PullCount .Page.Limit }} 165 + {{ template "fragments/pagination" (dict 166 + "Page" .Page 167 + "TotalCount" .PullCount 168 + "BasePath" (printf "/%s/pulls" .RepoInfo.FullName) 169 + "QueryParams" (printf "state=%s&q=%s" .FilteringBy.String .FilterQuery) 170 + ) }} 171 + {{ end }} 172 {{ end }} 173 174 {{ define "stackedPullList" }}
+1 -1
appview/pages/templates/repo/settings/pipelines.html
··· 22 <p class="text-gray-500 dark:text-gray-400"> 23 Choose a spindle to execute your workflows on. Only repository owners 24 can configure spindles. Spindles can be selfhosted, 25 - <a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 26 click to learn more. 27 </a> 28 </p>
··· 22 <p class="text-gray-500 dark:text-gray-400"> 23 Choose a spindle to execute your workflows on. Only repository owners 24 can configure spindles. Spindles can be selfhosted, 25 + <a class="text-gray-500 dark:text-gray-400 underline" href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 26 click to learn more. 27 </a> 28 </p>
+1 -1
appview/pages/templates/spindles/index.html
··· 102 {{ define "docsButton" }} 103 <a 104 class="btn flex items-center gap-2" 105 - href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 106 {{ i "book" "size-4" }} 107 docs 108 </a>
··· 102 {{ define "docsButton" }} 103 <a 104 class="btn flex items-center gap-2" 105 + href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 106 {{ i "book" "size-4" }} 107 docs 108 </a>
+6
appview/pages/templates/user/fragments/follow-oob.html
···
··· 1 + {{ define "user/fragments/follow-oob" }} 2 + {{ template "user/fragments/follow" . }} 3 + <span hx-swap-oob='innerHTML:[data-followers-did="{{ .UserDid }}"]'> 4 + <a href="/{{ resolve .UserDid }}?tab=followers">{{ .FollowersCount }} followers</a> 5 + </span> 6 + {{ end }}
+7 -5
appview/pages/templates/user/fragments/followCard.html
··· 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 </div> 8 9 - <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 <a href="/{{ $userIdent }}"> 12 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 </a> 14 {{ with .Profile }} 15 - <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 16 {{ end }} 17 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 19 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 20 <span class="select-none after:content-['ยท']"></span> 21 <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 22 </div> ··· 29 </div> 30 </div> 31 </div> 32 - {{ end }}
··· 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 </div> 8 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0"> 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 <a href="/{{ $userIdent }}"> 12 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ 13 + $userIdent | truncateAt30 }}</span> 14 </a> 15 {{ with .Profile }} 16 + <p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p> 17 {{ end }} 18 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 19 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 20 + <span id="followers" data-followers-did="{{ .UserDid }}"><a href="/{{ $userIdent }}?tab=followers">{{ 21 + .FollowersCount }} followers</a></span> 22 <span class="select-none after:content-['ยท']"></span> 23 <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 24 </div> ··· 31 </div> 32 </div> 33 </div> 34 + {{ end }}
+97 -99
appview/pages/templates/user/fragments/profileCard.html
··· 1 {{ define "user/fragments/profileCard" }} 2 - {{ $userIdent := resolve .UserDid }} 3 - <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 - <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 - <div class="w-3/4 aspect-square relative"> 6 - <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 7 - </div> 8 - </div> 9 - <div class="col-span-2"> 10 - <div class="flex items-center flex-row flex-nowrap gap-2"> 11 - <p title="{{ $userIdent }}" 12 - class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 - {{ $userIdent }} 14 - </p> 15 - {{ with .Profile }} 16 - {{ if .Pronouns }} 17 - <p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p> 18 - {{ end }} 19 - {{ end }} 20 - </div> 21 22 - <div class="md:hidden"> 23 - {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 24 - </div> 25 - </div> 26 - <div class="col-span-3 md:col-span-full"> 27 - <div id="profile-bio" class="text-sm"> 28 - {{ $profile := .Profile }} 29 - {{ with .Profile }} 30 31 - {{ if .Description }} 32 - <p class="text-base pb-4 md:pb-2">{{ .Description }}</p> 33 - {{ end }} 34 35 - <div class="hidden md:block"> 36 - {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 37 - </div> 38 39 - <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 40 - {{ if .Location }} 41 - <div class="flex items-center gap-2"> 42 - <span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span> 43 - <span>{{ .Location }}</span> 44 - </div> 45 - {{ end }} 46 - {{ if .IncludeBluesky }} 47 - <div class="flex items-center gap-2"> 48 - <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 49 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 50 - </div> 51 - {{ end }} 52 - {{ range $link := .Links }} 53 - {{ if $link }} 54 - <div class="flex items-center gap-2"> 55 - <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 56 - <a href="{{ $link }}">{{ $link }}</a> 57 - </div> 58 - {{ end }} 59 - {{ end }} 60 - {{ if not $profile.IsStatsEmpty }} 61 - <div class="flex items-center justify-evenly gap-2 py-2"> 62 - {{ range $stat := .Stats }} 63 - {{ if $stat.Kind }} 64 - <div class="flex flex-col items-center gap-2"> 65 - <span class="text-xl font-bold">{{ $stat.Value }}</span> 66 - <span>{{ $stat.Kind.String }}</span> 67 - </div> 68 - {{ end }} 69 - {{ end }} 70 - </div> 71 - {{ end }} 72 </div> 73 {{ end }} 74 - 75 - <div class="flex mt-2 items-center gap-2"> 76 - {{ if ne .FollowStatus.String "IsSelf" }} 77 - {{ template "user/fragments/follow" . }} 78 - {{ else }} 79 - <button id="editBtn" 80 - class="btn w-full flex items-center gap-2 group" 81 - hx-target="#profile-bio" 82 - hx-get="/profile/edit-bio" 83 - hx-swap="innerHTML"> 84 - {{ i "pencil" "w-4 h-4" }} 85 - edit 86 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 87 - </button> 88 - {{ end }} 89 90 - <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 91 - href="/{{ $userIdent }}/feed.atom"> 92 - {{ i "rss" "size-4" }} 93 - </a> 94 - </div> 95 96 - </div> 97 - <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 98 </div> 99 </div> 100 {{ end }} 101 102 {{ define "followerFollowing" }} 103 - {{ $root := index . 0 }} 104 - {{ $userIdent := index . 1 }} 105 - {{ with $root }} 106 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 107 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 108 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 109 - <span class="select-none after:content-['ยท']"></span> 110 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 111 - </div> 112 - {{ end }} 113 {{ end }} 114 -
··· 1 {{ define "user/fragments/profileCard" }} 2 + {{ $userIdent := resolve .UserDid }} 3 + <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 + <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 + <div class="w-3/4 aspect-square relative"> 6 + <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 7 + </div> 8 + </div> 9 + <div class="col-span-2"> 10 + <div class="flex items-center flex-row flex-nowrap gap-2"> 11 + <p title="{{ $userIdent }}" 12 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 + {{ $userIdent }} 14 + </p> 15 + {{ with .Profile }} 16 + {{ if .Pronouns }} 17 + <p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p> 18 + {{ end }} 19 + {{ end }} 20 + </div> 21 22 + <div class="md:hidden"> 23 + {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 24 + </div> 25 + </div> 26 + <div class="col-span-3 md:col-span-full"> 27 + <div id="profile-bio" class="text-sm"> 28 + {{ $profile := .Profile }} 29 + {{ with .Profile }} 30 31 + {{ if .Description }} 32 + <p class="text-base pb-4 md:pb-2">{{ .Description }}</p> 33 + {{ end }} 34 35 + <div class="hidden md:block"> 36 + {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 37 + </div> 38 39 + <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 40 + {{ if .Location }} 41 + <div class="flex items-center gap-2"> 42 + <span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span> 43 + <span>{{ .Location }}</span> 44 + </div> 45 + {{ end }} 46 + {{ if .IncludeBluesky }} 47 + <div class="flex items-center gap-2"> 48 + <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" 49 + }}</span> 50 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 51 + </div> 52 + {{ end }} 53 + {{ range $link := .Links }} 54 + {{ if $link }} 55 + <div class="flex items-center gap-2"> 56 + <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 57 + <a href="{{ $link }}">{{ $link }}</a> 58 + </div> 59 + {{ end }} 60 + {{ end }} 61 + {{ if not $profile.IsStatsEmpty }} 62 + <div class="flex items-center justify-evenly gap-2 py-2"> 63 + {{ range $stat := .Stats }} 64 + {{ if $stat.Kind }} 65 + <div class="flex flex-col items-center gap-2"> 66 + <span class="text-xl font-bold">{{ $stat.Value }}</span> 67 + <span>{{ $stat.Kind.String }}</span> 68 </div> 69 {{ end }} 70 + {{ end }} 71 + </div> 72 + {{ end }} 73 + </div> 74 + {{ end }} 75 76 + <div class="flex mt-2 items-center gap-2"> 77 + {{ if ne .FollowStatus.String "IsSelf" }} 78 + {{ template "user/fragments/follow" . }} 79 + {{ else }} 80 + <button id="editBtn" class="btn w-full flex items-center gap-2 group" hx-target="#profile-bio" 81 + hx-get="/profile/edit-bio" hx-swap="innerHTML"> 82 + {{ i "pencil" "w-4 h-4" }} 83 + edit 84 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 85 + </button> 86 + {{ end }} 87 88 + <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 89 + href="/{{ $userIdent }}/feed.atom"> 90 + {{ i "rss" "size-4" }} 91 + </a> 92 </div> 93 + 94 </div> 95 + <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 96 + </div> 97 + </div> 98 {{ end }} 99 100 {{ define "followerFollowing" }} 101 + {{ $root := index . 0 }} 102 + {{ $userIdent := index . 1 }} 103 + {{ with $root }} 104 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 105 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 106 + <span id="followers" data-followers-did="{{ .UserDid }}"><a href="/{{ $userIdent }}?tab=followers">{{ 107 + .Stats.FollowersCount }} followers</a></span> 108 + <span class="select-none after:content-['ยท']"></span> 109 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 110 + </div> 111 {{ end }} 112 + {{ end }}
+53
appview/pages/templates/user/login.html
··· 20 <h2 class="text-center text-xl italic dark:text-white"> 21 tightly-knit social coding. 22 </h2> 23 <form 24 class="mt-4" 25 hx-post="/login" ··· 46 </span> 47 </div> 48 <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 49 50 <button 51 class="btn w-full my-2 mt-6 text-base " ··· 66 You have not authorized the app. 67 {{ else if eq .ErrorCode "session" }} 68 Server failed to create user session. 69 {{ else }} 70 Internal Server error. 71 {{ end }}
··· 20 <h2 class="text-center text-xl italic dark:text-white"> 21 tightly-knit social coding. 22 </h2> 23 + 24 + {{ if .AddAccount }} 25 + <div class="flex gap-2 my-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-300 dark:border-sky-800 rounded px-3 py-2 text-blue-600 dark:text-blue-300"> 26 + <span class="py-1">{{ i "user-plus" "w-4 h-4" }}</span> 27 + <div> 28 + <h5 class="font-medium">Add another account</h5> 29 + <p class="text-sm">Sign in with a different account to add it to your account list.</p> 30 + </div> 31 + </div> 32 + {{ end }} 33 + 34 + {{ if and .LoggedInUser .LoggedInUser.Accounts }} 35 + {{ $accounts := .LoggedInUser.Accounts }} 36 + {{ if $accounts }} 37 + <div class="my-4 border border-gray-200 dark:border-gray-700 rounded overflow-hidden"> 38 + <div class="px-3 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"> 39 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium">Saved accounts</span> 40 + </div> 41 + <div class="divide-y divide-gray-200 dark:divide-gray-700"> 42 + {{ range $accounts }} 43 + <div class="flex items-center justify-between px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"> 44 + <button 45 + type="button" 46 + hx-post="/account/switch" 47 + hx-vals='{"did": "{{ .Did }}"}' 48 + hx-swap="none" 49 + class="flex items-center gap-2 flex-1 text-left min-w-0" 50 + > 51 + <img src="{{ tinyAvatar .Did }}" alt="" class="rounded-full h-8 w-8 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 52 + <div class="flex flex-col min-w-0"> 53 + <span class="text-sm font-medium dark:text-white truncate">{{ .Did | resolve | truncateAt30 }}</span> 54 + <span class="text-xs text-gray-500 dark:text-gray-400">Click to switch</span> 55 + </div> 56 + </button> 57 + <button 58 + type="button" 59 + hx-delete="/account/{{ .Did }}" 60 + hx-swap="none" 61 + class="p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 flex-shrink-0" 62 + title="Remove account" 63 + > 64 + {{ i "x" "w-4 h-4" }} 65 + </button> 66 + </div> 67 + {{ end }} 68 + </div> 69 + </div> 70 + {{ end }} 71 + {{ end }} 72 + 73 <form 74 class="mt-4" 75 hx-post="/login" ··· 96 </span> 97 </div> 98 <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 99 + <input type="hidden" name="add_account" value="{{ if .AddAccount }}true{{ end }}"> 100 101 <button 102 class="btn w-full my-2 mt-6 text-base " ··· 117 You have not authorized the app. 118 {{ else if eq .ErrorCode "session" }} 119 Server failed to create user session. 120 + {{ else if eq .ErrorCode "max_accounts" }} 121 + You have reached the maximum of 20 linked accounts. Please remove an account before adding a new one. 122 {{ else }} 123 Internal Server error. 124 {{ end }}
+87 -3
appview/pipelines/pipelines.go
··· 4 "bytes" 5 "context" 6 "encoding/json" 7 "log/slog" 8 "net/http" 9 "strings" 10 "time" 11 12 "tangled.org/core/appview/config" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/oauth" 15 "tangled.org/core/appview/pages" 16 "tangled.org/core/appview/reporesolver" ··· 36 logger *slog.Logger 37 } 38 39 - func (p *Pipelines) Router() http.Handler { 40 r := chi.NewRouter() 41 r.Get("/", p.Index) 42 r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 43 r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 44 45 return r 46 } ··· 70 } 71 72 func (p *Pipelines) Index(w http.ResponseWriter, r *http.Request) { 73 - user := p.oauth.GetUser(r) 74 l := p.logger.With("handler", "Index") 75 76 f, err := p.repoResolver.Resolve(r) ··· 99 } 100 101 func (p *Pipelines) Workflow(w http.ResponseWriter, r *http.Request) { 102 - user := p.oauth.GetUser(r) 103 l := p.logger.With("handler", "Workflow") 104 105 f, err := p.repoResolver.Resolve(r) ··· 314 } 315 } 316 } 317 } 318 319 // either a message or an error
··· 4 "bytes" 5 "context" 6 "encoding/json" 7 + "fmt" 8 "log/slog" 9 "net/http" 10 "strings" 11 "time" 12 13 + "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/config" 15 "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/middleware" 17 + "tangled.org/core/appview/models" 18 "tangled.org/core/appview/oauth" 19 "tangled.org/core/appview/pages" 20 "tangled.org/core/appview/reporesolver" ··· 40 logger *slog.Logger 41 } 42 43 + func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler { 44 r := chi.NewRouter() 45 r.Get("/", p.Index) 46 r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 47 r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 48 + r. 49 + With(mw.RepoPermissionMiddleware("repo:owner")). 50 + Post("/{pipeline}/workflow/{workflow}/cancel", p.Cancel) 51 52 return r 53 } ··· 77 } 78 79 func (p *Pipelines) Index(w http.ResponseWriter, r *http.Request) { 80 + user := p.oauth.GetMultiAccountUser(r) 81 l := p.logger.With("handler", "Index") 82 83 f, err := p.repoResolver.Resolve(r) ··· 106 } 107 108 func (p *Pipelines) Workflow(w http.ResponseWriter, r *http.Request) { 109 + user := p.oauth.GetMultiAccountUser(r) 110 l := p.logger.With("handler", "Workflow") 111 112 f, err := p.repoResolver.Resolve(r) ··· 321 } 322 } 323 } 324 + } 325 + 326 + func (p *Pipelines) Cancel(w http.ResponseWriter, r *http.Request) { 327 + l := p.logger.With("handler", "Cancel") 328 + 329 + var ( 330 + pipelineId = chi.URLParam(r, "pipeline") 331 + workflow = chi.URLParam(r, "workflow") 332 + ) 333 + if pipelineId == "" || workflow == "" { 334 + http.Error(w, "missing pipeline ID or workflow", http.StatusBadRequest) 335 + return 336 + } 337 + 338 + f, err := p.repoResolver.Resolve(r) 339 + if err != nil { 340 + l.Error("failed to get repo and knot", "err", err) 341 + http.Error(w, "bad repo/knot", http.StatusBadRequest) 342 + return 343 + } 344 + 345 + pipeline, err := func() (models.Pipeline, error) { 346 + ps, err := db.GetPipelineStatuses( 347 + p.db, 348 + 1, 349 + orm.FilterEq("repo_owner", f.Did), 350 + orm.FilterEq("repo_name", f.Name), 351 + orm.FilterEq("knot", f.Knot), 352 + orm.FilterEq("id", pipelineId), 353 + ) 354 + if err != nil { 355 + return models.Pipeline{}, err 356 + } 357 + if len(ps) != 1 { 358 + return models.Pipeline{}, fmt.Errorf("wrong pipeline count %d", len(ps)) 359 + } 360 + return ps[0], nil 361 + }() 362 + if err != nil { 363 + l.Error("pipeline query failed", "err", err) 364 + http.Error(w, "pipeline not found", http.StatusNotFound) 365 + } 366 + var ( 367 + spindle = f.Spindle 368 + knot = f.Knot 369 + rkey = pipeline.Rkey 370 + ) 371 + 372 + if spindle == "" || knot == "" || rkey == "" { 373 + http.Error(w, "invalid repo info", http.StatusBadRequest) 374 + return 375 + } 376 + 377 + spindleClient, err := p.oauth.ServiceClient( 378 + r, 379 + oauth.WithService(f.Spindle), 380 + oauth.WithLxm(tangled.PipelineCancelPipelineNSID), 381 + oauth.WithDev(p.config.Core.Dev), 382 + oauth.WithTimeout(time.Second*30), // workflow cleanup usually takes time 383 + ) 384 + 385 + err = tangled.PipelineCancelPipeline( 386 + r.Context(), 387 + spindleClient, 388 + &tangled.PipelineCancelPipeline_Input{ 389 + Repo: string(f.RepoAt()), 390 + Pipeline: pipeline.AtUri().String(), 391 + Workflow: workflow, 392 + }, 393 + ) 394 + errorId := "workflow-error" 395 + if err != nil { 396 + l.Error("failed to cancel workflow", "err", err) 397 + p.pages.Notice(w, errorId, "Failed to cancel workflow") 398 + return 399 + } 400 + l.Debug("canceled pipeline", "uri", pipeline.AtUri()) 401 } 402 403 // either a message or an error
+3 -3
appview/pulls/opengraph.go
··· 18 "tangled.org/core/types" 19 ) 20 21 - func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffStat, filesChanged int) (*ogcard.Card, error) { 22 width, height := ogcard.DefaultSize() 23 mainCard, err := ogcard.NewCard(width, height) 24 if err != nil { ··· 242 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 243 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 244 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 245 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 246 if err != nil { 247 log.Printf("dolly silhouette not available (this is ok): %v", err) 248 } ··· 284 commentCount := len(comments) 285 286 // Calculate diff stats from latest submission using patchutil 287 - var diffStats types.DiffStat 288 filesChanged := 0 289 if len(pull.Submissions) > 0 { 290 latestSubmission := pull.Submissions[len(pull.Submissions)-1]
··· 18 "tangled.org/core/types" 19 ) 20 21 + func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffFileStat, filesChanged int) (*ogcard.Card, error) { 22 width, height := ogcard.DefaultSize() 23 mainCard, err := ogcard.NewCard(width, height) 24 if err != nil { ··· 242 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 243 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 244 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 245 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 246 if err != nil { 247 log.Printf("dolly silhouette not available (this is ok): %v", err) 248 } ··· 284 commentCount := len(comments) 285 286 // Calculate diff stats from latest submission using patchutil 287 + var diffStats types.DiffFileStat 288 filesChanged := 0 289 if len(pull.Submissions) > 0 { 290 latestSubmission := pull.Submissions[len(pull.Submissions)-1]
+197 -196
appview/pulls/pulls.go
··· 1 package pulls 2 3 import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "log" 10 "log/slog" 11 "net/http" ··· 26 "tangled.org/core/appview/pages" 27 "tangled.org/core/appview/pages/markup" 28 "tangled.org/core/appview/pages/repoinfo" 29 "tangled.org/core/appview/reporesolver" 30 "tangled.org/core/appview/validator" 31 "tangled.org/core/appview/xrpcclient" ··· 93 func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 94 switch r.Method { 95 case http.MethodGet: 96 - user := s.oauth.GetUser(r) 97 f, err := s.repoResolver.Resolve(r) 98 if err != nil { 99 log.Println("failed to get repo and knot", err) ··· 124 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 125 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 126 resubmitResult := pages.Unknown 127 - if user.Did == pull.OwnerDid { 128 resubmitResult = s.resubmitCheck(r, f, pull, stack) 129 } 130 ··· 142 } 143 } 144 145 - func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 146 - user := s.oauth.GetUser(r) 147 f, err := s.repoResolver.Resolve(r) 148 if err != nil { 149 log.Println("failed to get repo and knot", err) ··· 164 return 165 } 166 167 // can be nil if this pull is not stacked 168 stack, _ := r.Context().Value("stack").(models.Stack) 169 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) ··· 171 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 172 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 173 resubmitResult := pages.Unknown 174 - if user != nil && user.Did == pull.OwnerDid { 175 resubmitResult = s.resubmitCheck(r, f, pull, stack) 176 } 177 ··· 213 214 userReactions := map[models.ReactionKind]bool{} 215 if user != nil { 216 - userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 217 } 218 219 labelDefs, err := db.GetLabelDefinitions( ··· 232 defs[l.AtUri().String()] = &l 233 } 234 235 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 236 LoggedInUser: user, 237 RepoInfo: s.repoResolver.GetRepoInfo(r, user), ··· 243 MergeCheck: mergeCheckResponse, 244 ResubmitCheck: resubmitResult, 245 Pipelines: m, 246 247 OrderedReactionKinds: models.OrderedReactionKinds, 248 Reactions: reactionMap, ··· 250 251 LabelDefs: defs, 252 }) 253 } 254 255 func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { ··· 324 return nil 325 } 326 327 - user := s.oauth.GetUser(r) 328 if user == nil { 329 return nil 330 } ··· 347 } 348 349 // user can only delete branch if they are a collaborator in the repo that the branch belongs to 350 - perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 351 if !slices.Contains(perms, "repo:push") { 352 return nil 353 } ··· 434 } 435 436 func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 437 - user := s.oauth.GetUser(r) 438 - 439 - var diffOpts types.DiffOpts 440 - if d := r.URL.Query().Get("diff"); d == "split" { 441 - diffOpts.Split = true 442 - } 443 - 444 - pull, ok := r.Context().Value("pull").(*models.Pull) 445 - if !ok { 446 - log.Println("failed to get pull") 447 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 448 - return 449 - } 450 - 451 - stack, _ := r.Context().Value("stack").(models.Stack) 452 - 453 - roundId := chi.URLParam(r, "round") 454 - roundIdInt, err := strconv.Atoi(roundId) 455 - if err != nil || roundIdInt >= len(pull.Submissions) { 456 - http.Error(w, "bad round id", http.StatusBadRequest) 457 - log.Println("failed to parse round id", err) 458 - return 459 - } 460 - 461 - patch := pull.Submissions[roundIdInt].CombinedPatch() 462 - diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 463 - 464 - s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 465 - LoggedInUser: user, 466 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 467 - Pull: pull, 468 - Stack: stack, 469 - Round: roundIdInt, 470 - Submission: pull.Submissions[roundIdInt], 471 - Diff: &diff, 472 - DiffOpts: diffOpts, 473 - }) 474 - 475 } 476 477 func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 478 - user := s.oauth.GetUser(r) 479 - 480 - var diffOpts types.DiffOpts 481 - if d := r.URL.Query().Get("diff"); d == "split" { 482 - diffOpts.Split = true 483 - } 484 - 485 - pull, ok := r.Context().Value("pull").(*models.Pull) 486 - if !ok { 487 - log.Println("failed to get pull") 488 - s.pages.Notice(w, "pull-error", "Failed to get pull.") 489 - return 490 - } 491 - 492 - roundId := chi.URLParam(r, "round") 493 - roundIdInt, err := strconv.Atoi(roundId) 494 - if err != nil || roundIdInt >= len(pull.Submissions) { 495 - http.Error(w, "bad round id", http.StatusBadRequest) 496 - log.Println("failed to parse round id", err) 497 - return 498 - } 499 - 500 - if roundIdInt == 0 { 501 - http.Error(w, "bad round id", http.StatusBadRequest) 502 - log.Println("cannot interdiff initial submission") 503 - return 504 - } 505 - 506 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 507 - if err != nil { 508 - log.Println("failed to interdiff; current patch malformed") 509 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 510 - return 511 - } 512 - 513 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 514 - if err != nil { 515 - log.Println("failed to interdiff; previous patch malformed") 516 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 517 - return 518 - } 519 - 520 - interdiff := patchutil.Interdiff(previousPatch, currentPatch) 521 - 522 - s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 523 - LoggedInUser: s.oauth.GetUser(r), 524 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 525 - Pull: pull, 526 - Round: roundIdInt, 527 - Interdiff: interdiff, 528 - DiffOpts: diffOpts, 529 - }) 530 } 531 532 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { ··· 552 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 553 l := s.logger.With("handler", "RepoPulls") 554 555 - user := s.oauth.GetUser(r) 556 params := r.URL.Query() 557 558 state := models.PullOpen ··· 563 state = models.PullMerged 564 } 565 566 f, err := s.repoResolver.Resolve(r) 567 if err != nil { 568 log.Println("failed to get repo and knot", err) 569 return 570 } 571 572 keyword := params.Get("q") 573 574 - var ids []int64 575 searchOpts := models.PullSearchOptions{ 576 Keyword: keyword, 577 RepoAt: f.RepoAt().String(), 578 State: state, 579 - // Page: page, 580 } 581 l.Debug("searching with", "searchOpts", searchOpts) 582 if keyword != "" { ··· 585 l.Error("failed to search for pulls", "err", err) 586 return 587 } 588 - ids = res.Hits 589 - l.Debug("searched pulls with indexer", "count", len(ids)) 590 } else { 591 - ids, err = db.GetPullIDs(s.db, searchOpts) 592 if err != nil { 593 - l.Error("failed to get all pull ids", "err", err) 594 return 595 } 596 - l.Debug("indexed all pulls from the db", "count", len(ids)) 597 - } 598 - 599 - pulls, err := db.GetPulls( 600 - s.db, 601 - orm.FilterIn("id", ids), 602 - ) 603 - if err != nil { 604 - log.Println("failed to get pulls", err) 605 - s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 606 - return 607 } 608 609 for _, p := range pulls { ··· 680 } 681 682 s.pages.RepoPulls(w, pages.RepoPullsParams{ 683 - LoggedInUser: s.oauth.GetUser(r), 684 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 685 Pulls: pulls, 686 LabelDefs: defs, ··· 688 FilterQuery: keyword, 689 Stacks: stacks, 690 Pipelines: m, 691 }) 692 } 693 694 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 695 - user := s.oauth.GetUser(r) 696 f, err := s.repoResolver.Resolve(r) 697 if err != nil { 698 log.Println("failed to get repo and knot", err) ··· 751 } 752 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 753 Collection: tangled.RepoPullCommentNSID, 754 - Repo: user.Did, 755 Rkey: tid.TID(), 756 Record: &lexutil.LexiconTypeDecoder{ 757 Val: &tangled.RepoPullComment{ ··· 768 } 769 770 comment := &models.PullComment{ 771 - OwnerDid: user.Did, 772 RepoAt: f.RepoAt().String(), 773 PullId: pull.PullId, 774 Body: body, ··· 802 } 803 804 func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 805 - user := s.oauth.GetUser(r) 806 f, err := s.repoResolver.Resolve(r) 807 if err != nil { 808 log.Println("failed to get repo and knot", err) ··· 870 } 871 872 // Determine PR type based on input parameters 873 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 874 isPushAllowed := roles.IsPushAllowed() 875 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 876 isForkBased := fromFork != "" && sourceBranch != "" ··· 970 w http.ResponseWriter, 971 r *http.Request, 972 repo *models.Repo, 973 - user *oauth.User, 974 title, 975 body, 976 targetBranch, ··· 1027 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1028 } 1029 1030 - func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1031 if err := s.validator.ValidatePatch(&patch); err != nil { 1032 s.logger.Error("patch validation failed", "err", err) 1033 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") ··· 1037 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1038 } 1039 1040 - func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1041 repoString := strings.SplitN(forkRepo, "/", 2) 1042 forkOwnerDid := repoString[0] 1043 repoName := repoString[1] ··· 1146 w http.ResponseWriter, 1147 r *http.Request, 1148 repo *models.Repo, 1149 - user *oauth.User, 1150 title, body, targetBranch string, 1151 patch string, 1152 combined string, ··· 1218 Title: title, 1219 Body: body, 1220 TargetBranch: targetBranch, 1221 - OwnerDid: user.Did, 1222 RepoAt: repo.RepoAt(), 1223 Rkey: rkey, 1224 Mentions: mentions, ··· 1241 return 1242 } 1243 1244 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1245 Collection: tangled.RepoPullNSID, 1246 - Repo: user.Did, 1247 Rkey: rkey, 1248 Record: &lexutil.LexiconTypeDecoder{ 1249 Val: &tangled.RepoPull{ ··· 1252 Repo: string(repo.RepoAt()), 1253 Branch: targetBranch, 1254 }, 1255 - Patch: patch, 1256 Source: recordPullSource, 1257 CreatedAt: time.Now().Format(time.RFC3339), 1258 }, ··· 1280 w http.ResponseWriter, 1281 r *http.Request, 1282 repo *models.Repo, 1283 - user *oauth.User, 1284 targetBranch string, 1285 patch string, 1286 sourceRev string, ··· 1328 // apply all record creations at once 1329 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1330 for _, p := range stack { 1331 record := p.AsRecord() 1332 - write := comatproto.RepoApplyWrites_Input_Writes_Elem{ 1333 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1334 Collection: tangled.RepoPullNSID, 1335 Rkey: &p.Rkey, ··· 1337 Val: &record, 1338 }, 1339 }, 1340 - } 1341 - writes = append(writes, &write) 1342 } 1343 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1344 - Repo: user.Did, 1345 Writes: writes, 1346 }) 1347 if err != nil { ··· 1413 } 1414 1415 func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1416 - user := s.oauth.GetUser(r) 1417 1418 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1419 RepoInfo: s.repoResolver.GetRepoInfo(r, user), ··· 1421 } 1422 1423 func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 1424 - user := s.oauth.GetUser(r) 1425 f, err := s.repoResolver.Resolve(r) 1426 if err != nil { 1427 log.Println("failed to get repo and knot", err) ··· 1476 } 1477 1478 func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1479 - user := s.oauth.GetUser(r) 1480 1481 - forks, err := db.GetForksByDid(s.db, user.Did) 1482 if err != nil { 1483 log.Println("failed to get forks", err) 1484 return ··· 1492 } 1493 1494 func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1495 - user := s.oauth.GetUser(r) 1496 1497 f, err := s.repoResolver.Resolve(r) 1498 if err != nil { ··· 1585 } 1586 1587 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1588 - user := s.oauth.GetUser(r) 1589 1590 pull, ok := r.Context().Value("pull").(*models.Pull) 1591 if !ok { ··· 1616 } 1617 1618 func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1619 - user := s.oauth.GetUser(r) 1620 1621 pull, ok := r.Context().Value("pull").(*models.Pull) 1622 if !ok { ··· 1631 return 1632 } 1633 1634 - if user.Did != pull.OwnerDid { 1635 log.Println("unauthorized user") 1636 w.WriteHeader(http.StatusUnauthorized) 1637 return ··· 1643 } 1644 1645 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1646 - user := s.oauth.GetUser(r) 1647 1648 pull, ok := r.Context().Value("pull").(*models.Pull) 1649 if !ok { ··· 1658 return 1659 } 1660 1661 - if user.Did != pull.OwnerDid { 1662 log.Println("unauthorized user") 1663 w.WriteHeader(http.StatusUnauthorized) 1664 return 1665 } 1666 1667 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 1668 if !roles.IsPushAllowed() { 1669 log.Println("unauthorized user") 1670 w.WriteHeader(http.StatusUnauthorized) ··· 1708 } 1709 1710 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1711 - user := s.oauth.GetUser(r) 1712 1713 pull, ok := r.Context().Value("pull").(*models.Pull) 1714 if !ok { ··· 1723 return 1724 } 1725 1726 - if user.Did != pull.OwnerDid { 1727 log.Println("unauthorized user") 1728 w.WriteHeader(http.StatusUnauthorized) 1729 return ··· 1808 w http.ResponseWriter, 1809 r *http.Request, 1810 repo *models.Repo, 1811 - user *oauth.User, 1812 pull *models.Pull, 1813 patch string, 1814 combined string, ··· 1864 return 1865 } 1866 1867 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1868 if err != nil { 1869 // failed to get record 1870 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1871 return 1872 } 1873 1874 - var recordPullSource *tangled.RepoPull_Source 1875 - if pull.IsBranchBased() { 1876 - recordPullSource = &tangled.RepoPull_Source{ 1877 - Branch: pull.PullSource.Branch, 1878 - Sha: sourceRev, 1879 - } 1880 } 1881 - if pull.IsForkBased() { 1882 - repoAt := pull.PullSource.RepoAt.String() 1883 - recordPullSource = &tangled.RepoPull_Source{ 1884 - Branch: pull.PullSource.Branch, 1885 - Repo: &repoAt, 1886 - Sha: sourceRev, 1887 - } 1888 - } 1889 1890 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1891 Collection: tangled.RepoPullNSID, 1892 - Repo: user.Did, 1893 Rkey: pull.Rkey, 1894 SwapRecord: ex.Cid, 1895 Record: &lexutil.LexiconTypeDecoder{ 1896 - Val: &tangled.RepoPull{ 1897 - Title: pull.Title, 1898 - Target: &tangled.RepoPull_Target{ 1899 - Repo: string(repo.RepoAt()), 1900 - Branch: pull.TargetBranch, 1901 - }, 1902 - Patch: patch, // new patch 1903 - Source: recordPullSource, 1904 - CreatedAt: time.Now().Format(time.RFC3339), 1905 - }, 1906 }, 1907 }) 1908 if err != nil { ··· 1925 w http.ResponseWriter, 1926 r *http.Request, 1927 repo *models.Repo, 1928 - user *oauth.User, 1929 pull *models.Pull, 1930 patch string, 1931 stackId string, ··· 1988 } 1989 defer tx.Rollback() 1990 1991 // pds updates to make 1992 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1993 ··· 2021 return 2022 } 2023 2024 record := p.AsRecord() 2025 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2026 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2027 Collection: tangled.RepoPullNSID, ··· 2056 return 2057 } 2058 2059 record := np.AsRecord() 2060 - 2061 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2062 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2063 Collection: tangled.RepoPullNSID, ··· 2094 return 2095 } 2096 2097 - client, err := s.oauth.AuthorizedClient(r) 2098 - if err != nil { 2099 - log.Println("failed to authorize client") 2100 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2101 - return 2102 - } 2103 - 2104 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2105 - Repo: user.Did, 2106 Writes: writes, 2107 }) 2108 if err != nil { ··· 2116 } 2117 2118 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2119 - user := s.oauth.GetUser(r) 2120 f, err := s.repoResolver.Resolve(r) 2121 if err != nil { 2122 log.Println("failed to resolve repo:", err) ··· 2227 2228 // notify about the pull merge 2229 for _, p := range pullsToMerge { 2230 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2231 } 2232 2233 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) ··· 2235 } 2236 2237 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2238 - user := s.oauth.GetUser(r) 2239 2240 f, err := s.repoResolver.Resolve(r) 2241 if err != nil { ··· 2251 } 2252 2253 // auth filter: only owner or collaborators can close 2254 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2255 isOwner := roles.IsOwner() 2256 isCollaborator := roles.IsCollaborator() 2257 - isPullAuthor := user.Did == pull.OwnerDid 2258 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2259 if !isCloseAllowed { 2260 log.Println("failed to close pull") ··· 2300 } 2301 2302 for _, p := range pullsToClose { 2303 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2304 } 2305 2306 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) ··· 2308 } 2309 2310 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2311 - user := s.oauth.GetUser(r) 2312 2313 f, err := s.repoResolver.Resolve(r) 2314 if err != nil { ··· 2325 } 2326 2327 // auth filter: only owner or collaborators can close 2328 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2329 isOwner := roles.IsOwner() 2330 isCollaborator := roles.IsCollaborator() 2331 - isPullAuthor := user.Did == pull.OwnerDid 2332 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2333 if !isCloseAllowed { 2334 log.Println("failed to close pull") ··· 2374 } 2375 2376 for _, p := range pullsToReopen { 2377 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2378 } 2379 2380 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2381 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2382 } 2383 2384 - func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2385 formatPatches, err := patchutil.ExtractPatches(patch) 2386 if err != nil { 2387 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2417 Title: title, 2418 Body: body, 2419 TargetBranch: targetBranch, 2420 - OwnerDid: user.Did, 2421 RepoAt: repo.RepoAt(), 2422 Rkey: rkey, 2423 Mentions: mentions, ··· 2440 2441 return stack, nil 2442 }
··· 1 package pulls 2 3 import ( 4 + "bytes" 5 + "compress/gzip" 6 "context" 7 "database/sql" 8 "encoding/json" 9 "errors" 10 "fmt" 11 + "io" 12 "log" 13 "log/slog" 14 "net/http" ··· 29 "tangled.org/core/appview/pages" 30 "tangled.org/core/appview/pages/markup" 31 "tangled.org/core/appview/pages/repoinfo" 32 + "tangled.org/core/appview/pagination" 33 "tangled.org/core/appview/reporesolver" 34 "tangled.org/core/appview/validator" 35 "tangled.org/core/appview/xrpcclient" ··· 97 func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 98 switch r.Method { 99 case http.MethodGet: 100 + user := s.oauth.GetMultiAccountUser(r) 101 f, err := s.repoResolver.Resolve(r) 102 if err != nil { 103 log.Println("failed to get repo and knot", err) ··· 128 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 129 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 130 resubmitResult := pages.Unknown 131 + if user.Active.Did == pull.OwnerDid { 132 resubmitResult = s.resubmitCheck(r, f, pull, stack) 133 } 134 ··· 146 } 147 } 148 149 + func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) { 150 + user := s.oauth.GetMultiAccountUser(r) 151 f, err := s.repoResolver.Resolve(r) 152 if err != nil { 153 log.Println("failed to get repo and knot", err) ··· 168 return 169 } 170 171 + roundId := chi.URLParam(r, "round") 172 + roundIdInt := pull.LastRoundNumber() 173 + if r, err := strconv.Atoi(roundId); err == nil { 174 + roundIdInt = r 175 + } 176 + if roundIdInt >= len(pull.Submissions) { 177 + http.Error(w, "bad round id", http.StatusBadRequest) 178 + log.Println("failed to parse round id", err) 179 + return 180 + } 181 + 182 + var diffOpts types.DiffOpts 183 + if d := r.URL.Query().Get("diff"); d == "split" { 184 + diffOpts.Split = true 185 + } 186 + 187 // can be nil if this pull is not stacked 188 stack, _ := r.Context().Value("stack").(models.Stack) 189 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) ··· 191 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 192 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 193 resubmitResult := pages.Unknown 194 + if user != nil && user.Active != nil && user.Active.Did == pull.OwnerDid { 195 resubmitResult = s.resubmitCheck(r, f, pull, stack) 196 } 197 ··· 233 234 userReactions := map[models.ReactionKind]bool{} 235 if user != nil { 236 + userReactions = db.GetReactionStatusMap(s.db, user.Active.Did, pull.AtUri()) 237 } 238 239 labelDefs, err := db.GetLabelDefinitions( ··· 252 defs[l.AtUri().String()] = &l 253 } 254 255 + patch := pull.Submissions[roundIdInt].CombinedPatch() 256 + var diff types.DiffRenderer 257 + diff = patchutil.AsNiceDiff(patch, pull.TargetBranch) 258 + 259 + if interdiff { 260 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 261 + if err != nil { 262 + log.Println("failed to interdiff; current patch malformed") 263 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 264 + return 265 + } 266 + 267 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 268 + if err != nil { 269 + log.Println("failed to interdiff; previous patch malformed") 270 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 271 + return 272 + } 273 + 274 + diff = patchutil.Interdiff(previousPatch, currentPatch) 275 + } 276 + 277 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 278 LoggedInUser: user, 279 RepoInfo: s.repoResolver.GetRepoInfo(r, user), ··· 285 MergeCheck: mergeCheckResponse, 286 ResubmitCheck: resubmitResult, 287 Pipelines: m, 288 + Diff: diff, 289 + DiffOpts: diffOpts, 290 + ActiveRound: roundIdInt, 291 + IsInterdiff: interdiff, 292 293 OrderedReactionKinds: models.OrderedReactionKinds, 294 Reactions: reactionMap, ··· 296 297 LabelDefs: defs, 298 }) 299 + } 300 + 301 + func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 302 + s.repoPullHelper(w, r, false) 303 } 304 305 func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { ··· 374 return nil 375 } 376 377 + user := s.oauth.GetMultiAccountUser(r) 378 if user == nil { 379 return nil 380 } ··· 397 } 398 399 // user can only delete branch if they are a collaborator in the repo that the branch belongs to 400 + perms := s.enforcer.GetPermissionsInRepo(user.Active.Did, repo.Knot, repo.DidSlashRepo()) 401 if !slices.Contains(perms, "repo:push") { 402 return nil 403 } ··· 484 } 485 486 func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 487 + s.repoPullHelper(w, r, false) 488 } 489 490 func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 491 + s.repoPullHelper(w, r, true) 492 } 493 494 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { ··· 514 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 515 l := s.logger.With("handler", "RepoPulls") 516 517 + user := s.oauth.GetMultiAccountUser(r) 518 params := r.URL.Query() 519 520 state := models.PullOpen ··· 525 state = models.PullMerged 526 } 527 528 + page := pagination.FromContext(r.Context()) 529 + 530 f, err := s.repoResolver.Resolve(r) 531 if err != nil { 532 log.Println("failed to get repo and knot", err) 533 return 534 } 535 536 + var totalPulls int 537 + switch state { 538 + case models.PullOpen: 539 + totalPulls = f.RepoStats.PullCount.Open 540 + case models.PullMerged: 541 + totalPulls = f.RepoStats.PullCount.Merged 542 + case models.PullClosed: 543 + totalPulls = f.RepoStats.PullCount.Closed 544 + } 545 + 546 keyword := params.Get("q") 547 548 + var pulls []*models.Pull 549 searchOpts := models.PullSearchOptions{ 550 Keyword: keyword, 551 RepoAt: f.RepoAt().String(), 552 State: state, 553 + Page: page, 554 } 555 l.Debug("searching with", "searchOpts", searchOpts) 556 if keyword != "" { ··· 559 l.Error("failed to search for pulls", "err", err) 560 return 561 } 562 + totalPulls = int(res.Total) 563 + l.Debug("searched pulls with indexer", "count", len(res.Hits)) 564 + 565 + pulls, err = db.GetPulls( 566 + s.db, 567 + orm.FilterIn("id", res.Hits), 568 + ) 569 + if err != nil { 570 + log.Println("failed to get pulls", err) 571 + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 572 + return 573 + } 574 } else { 575 + pulls, err = db.GetPullsPaginated( 576 + s.db, 577 + page, 578 + orm.FilterEq("repo_at", f.RepoAt()), 579 + orm.FilterEq("state", searchOpts.State), 580 + ) 581 if err != nil { 582 + log.Println("failed to get pulls", err) 583 + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 584 return 585 } 586 } 587 588 for _, p := range pulls { ··· 659 } 660 661 s.pages.RepoPulls(w, pages.RepoPullsParams{ 662 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 663 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 664 Pulls: pulls, 665 LabelDefs: defs, ··· 667 FilterQuery: keyword, 668 Stacks: stacks, 669 Pipelines: m, 670 + Page: page, 671 + PullCount: totalPulls, 672 }) 673 } 674 675 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 676 + user := s.oauth.GetMultiAccountUser(r) 677 f, err := s.repoResolver.Resolve(r) 678 if err != nil { 679 log.Println("failed to get repo and knot", err) ··· 732 } 733 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 734 Collection: tangled.RepoPullCommentNSID, 735 + Repo: user.Active.Did, 736 Rkey: tid.TID(), 737 Record: &lexutil.LexiconTypeDecoder{ 738 Val: &tangled.RepoPullComment{ ··· 749 } 750 751 comment := &models.PullComment{ 752 + OwnerDid: user.Active.Did, 753 RepoAt: f.RepoAt().String(), 754 PullId: pull.PullId, 755 Body: body, ··· 783 } 784 785 func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 786 + user := s.oauth.GetMultiAccountUser(r) 787 f, err := s.repoResolver.Resolve(r) 788 if err != nil { 789 log.Println("failed to get repo and knot", err) ··· 851 } 852 853 // Determine PR type based on input parameters 854 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 855 isPushAllowed := roles.IsPushAllowed() 856 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 857 isForkBased := fromFork != "" && sourceBranch != "" ··· 951 w http.ResponseWriter, 952 r *http.Request, 953 repo *models.Repo, 954 + user *oauth.MultiAccountUser, 955 title, 956 body, 957 targetBranch, ··· 1008 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1009 } 1010 1011 + func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, title, body, targetBranch, patch string, isStacked bool) { 1012 if err := s.validator.ValidatePatch(&patch); err != nil { 1013 s.logger.Error("patch validation failed", "err", err) 1014 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") ··· 1018 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1019 } 1020 1021 + func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1022 repoString := strings.SplitN(forkRepo, "/", 2) 1023 forkOwnerDid := repoString[0] 1024 repoName := repoString[1] ··· 1127 w http.ResponseWriter, 1128 r *http.Request, 1129 repo *models.Repo, 1130 + user *oauth.MultiAccountUser, 1131 title, body, targetBranch string, 1132 patch string, 1133 combined string, ··· 1199 Title: title, 1200 Body: body, 1201 TargetBranch: targetBranch, 1202 + OwnerDid: user.Active.Did, 1203 RepoAt: repo.RepoAt(), 1204 Rkey: rkey, 1205 Mentions: mentions, ··· 1222 return 1223 } 1224 1225 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 1226 + if err != nil { 1227 + log.Println("failed to upload patch", err) 1228 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1229 + return 1230 + } 1231 + 1232 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1233 Collection: tangled.RepoPullNSID, 1234 + Repo: user.Active.Did, 1235 Rkey: rkey, 1236 Record: &lexutil.LexiconTypeDecoder{ 1237 Val: &tangled.RepoPull{ ··· 1240 Repo: string(repo.RepoAt()), 1241 Branch: targetBranch, 1242 }, 1243 + PatchBlob: blob.Blob, 1244 Source: recordPullSource, 1245 CreatedAt: time.Now().Format(time.RFC3339), 1246 }, ··· 1268 w http.ResponseWriter, 1269 r *http.Request, 1270 repo *models.Repo, 1271 + user *oauth.MultiAccountUser, 1272 targetBranch string, 1273 patch string, 1274 sourceRev string, ··· 1316 // apply all record creations at once 1317 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1318 for _, p := range stack { 1319 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch())) 1320 + if err != nil { 1321 + log.Println("failed to upload patch blob", err) 1322 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1323 + return 1324 + } 1325 + 1326 record := p.AsRecord() 1327 + record.PatchBlob = blob.Blob 1328 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1329 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1330 Collection: tangled.RepoPullNSID, 1331 Rkey: &p.Rkey, ··· 1333 Val: &record, 1334 }, 1335 }, 1336 + }) 1337 } 1338 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1339 + Repo: user.Active.Did, 1340 Writes: writes, 1341 }) 1342 if err != nil { ··· 1408 } 1409 1410 func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1411 + user := s.oauth.GetMultiAccountUser(r) 1412 1413 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1414 RepoInfo: s.repoResolver.GetRepoInfo(r, user), ··· 1416 } 1417 1418 func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 1419 + user := s.oauth.GetMultiAccountUser(r) 1420 f, err := s.repoResolver.Resolve(r) 1421 if err != nil { 1422 log.Println("failed to get repo and knot", err) ··· 1471 } 1472 1473 func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1474 + user := s.oauth.GetMultiAccountUser(r) 1475 1476 + forks, err := db.GetForksByDid(s.db, user.Active.Did) 1477 if err != nil { 1478 log.Println("failed to get forks", err) 1479 return ··· 1487 } 1488 1489 func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1490 + user := s.oauth.GetMultiAccountUser(r) 1491 1492 f, err := s.repoResolver.Resolve(r) 1493 if err != nil { ··· 1580 } 1581 1582 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1583 + user := s.oauth.GetMultiAccountUser(r) 1584 1585 pull, ok := r.Context().Value("pull").(*models.Pull) 1586 if !ok { ··· 1611 } 1612 1613 func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1614 + user := s.oauth.GetMultiAccountUser(r) 1615 1616 pull, ok := r.Context().Value("pull").(*models.Pull) 1617 if !ok { ··· 1626 return 1627 } 1628 1629 + if user.Active.Did != pull.OwnerDid { 1630 log.Println("unauthorized user") 1631 w.WriteHeader(http.StatusUnauthorized) 1632 return ··· 1638 } 1639 1640 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1641 + user := s.oauth.GetMultiAccountUser(r) 1642 1643 pull, ok := r.Context().Value("pull").(*models.Pull) 1644 if !ok { ··· 1653 return 1654 } 1655 1656 + if user.Active.Did != pull.OwnerDid { 1657 log.Println("unauthorized user") 1658 w.WriteHeader(http.StatusUnauthorized) 1659 return 1660 } 1661 1662 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 1663 if !roles.IsPushAllowed() { 1664 log.Println("unauthorized user") 1665 w.WriteHeader(http.StatusUnauthorized) ··· 1703 } 1704 1705 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1706 + user := s.oauth.GetMultiAccountUser(r) 1707 1708 pull, ok := r.Context().Value("pull").(*models.Pull) 1709 if !ok { ··· 1718 return 1719 } 1720 1721 + if user.Active.Did != pull.OwnerDid { 1722 log.Println("unauthorized user") 1723 w.WriteHeader(http.StatusUnauthorized) 1724 return ··· 1803 w http.ResponseWriter, 1804 r *http.Request, 1805 repo *models.Repo, 1806 + user *oauth.MultiAccountUser, 1807 pull *models.Pull, 1808 patch string, 1809 combined string, ··· 1859 return 1860 } 1861 1862 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Active.Did, pull.Rkey) 1863 if err != nil { 1864 // failed to get record 1865 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1866 return 1867 } 1868 1869 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 1870 + if err != nil { 1871 + log.Println("failed to upload patch blob", err) 1872 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1873 + return 1874 } 1875 + record := pull.AsRecord() 1876 + record.PatchBlob = blob.Blob 1877 + record.CreatedAt = time.Now().Format(time.RFC3339) 1878 1879 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1880 Collection: tangled.RepoPullNSID, 1881 + Repo: user.Active.Did, 1882 Rkey: pull.Rkey, 1883 SwapRecord: ex.Cid, 1884 Record: &lexutil.LexiconTypeDecoder{ 1885 + Val: &record, 1886 }, 1887 }) 1888 if err != nil { ··· 1905 w http.ResponseWriter, 1906 r *http.Request, 1907 repo *models.Repo, 1908 + user *oauth.MultiAccountUser, 1909 pull *models.Pull, 1910 patch string, 1911 stackId string, ··· 1968 } 1969 defer tx.Rollback() 1970 1971 + client, err := s.oauth.AuthorizedClient(r) 1972 + if err != nil { 1973 + log.Println("failed to authorize client") 1974 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1975 + return 1976 + } 1977 + 1978 // pds updates to make 1979 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1980 ··· 2008 return 2009 } 2010 2011 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 2012 + if err != nil { 2013 + log.Println("failed to upload patch blob", err) 2014 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2015 + return 2016 + } 2017 record := p.AsRecord() 2018 + record.PatchBlob = blob.Blob 2019 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2020 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2021 Collection: tangled.RepoPullNSID, ··· 2050 return 2051 } 2052 2053 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 2054 + if err != nil { 2055 + log.Println("failed to upload patch blob", err) 2056 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2057 + return 2058 + } 2059 record := np.AsRecord() 2060 + record.PatchBlob = blob.Blob 2061 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2062 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2063 Collection: tangled.RepoPullNSID, ··· 2094 return 2095 } 2096 2097 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2098 + Repo: user.Active.Did, 2099 Writes: writes, 2100 }) 2101 if err != nil { ··· 2109 } 2110 2111 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2112 + user := s.oauth.GetMultiAccountUser(r) 2113 f, err := s.repoResolver.Resolve(r) 2114 if err != nil { 2115 log.Println("failed to resolve repo:", err) ··· 2220 2221 // notify about the pull merge 2222 for _, p := range pullsToMerge { 2223 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2224 } 2225 2226 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) ··· 2228 } 2229 2230 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2231 + user := s.oauth.GetMultiAccountUser(r) 2232 2233 f, err := s.repoResolver.Resolve(r) 2234 if err != nil { ··· 2244 } 2245 2246 // auth filter: only owner or collaborators can close 2247 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 2248 isOwner := roles.IsOwner() 2249 isCollaborator := roles.IsCollaborator() 2250 + isPullAuthor := user.Active.Did == pull.OwnerDid 2251 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2252 if !isCloseAllowed { 2253 log.Println("failed to close pull") ··· 2293 } 2294 2295 for _, p := range pullsToClose { 2296 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2297 } 2298 2299 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) ··· 2301 } 2302 2303 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2304 + user := s.oauth.GetMultiAccountUser(r) 2305 2306 f, err := s.repoResolver.Resolve(r) 2307 if err != nil { ··· 2318 } 2319 2320 // auth filter: only owner or collaborators can close 2321 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 2322 isOwner := roles.IsOwner() 2323 isCollaborator := roles.IsCollaborator() 2324 + isPullAuthor := user.Active.Did == pull.OwnerDid 2325 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2326 if !isCloseAllowed { 2327 log.Println("failed to close pull") ··· 2367 } 2368 2369 for _, p := range pullsToReopen { 2370 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2371 } 2372 2373 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2374 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2375 } 2376 2377 + func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.MultiAccountUser, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2378 formatPatches, err := patchutil.ExtractPatches(patch) 2379 if err != nil { 2380 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2410 Title: title, 2411 Body: body, 2412 TargetBranch: targetBranch, 2413 + OwnerDid: user.Active.Did, 2414 RepoAt: repo.RepoAt(), 2415 Rkey: rkey, 2416 Mentions: mentions, ··· 2433 2434 return stack, nil 2435 } 2436 + 2437 + func gz(s string) io.Reader { 2438 + var b bytes.Buffer 2439 + w := gzip.NewWriter(&b) 2440 + w.Write([]byte(s)) 2441 + w.Close() 2442 + return &b 2443 + }
+1 -1
appview/pulls/router.go
··· 9 10 func (s *Pulls) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 - r.Get("/", s.RepoPulls) 13 r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) { 14 r.Get("/", s.NewPull) 15 r.Get("/patch-upload", s.PatchUploadFragment)
··· 9 10 func (s *Pulls) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 + r.With(middleware.Paginate).Get("/", s.RepoPulls) 13 r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) { 14 r.Get("/", s.NewPull) 15 r.Get("/patch-upload", s.PatchUploadFragment)
+1
appview/repo/archive.go
··· 18 l := rp.logger.With("handler", "DownloadArchive") 19 ref := chi.URLParam(r, "ref") 20 ref, _ = url.PathUnescape(ref) 21 f, err := rp.repoResolver.Resolve(r) 22 if err != nil { 23 l.Error("failed to get repo and knot", "err", err)
··· 18 l := rp.logger.With("handler", "DownloadArchive") 19 ref := chi.URLParam(r, "ref") 20 ref, _ = url.PathUnescape(ref) 21 + ref = strings.TrimSuffix(ref, ".tar.gz") 22 f, err := rp.repoResolver.Resolve(r) 23 if err != nil { 24 l.Error("failed to get repo and knot", "err", err)
+6 -6
appview/repo/artifact.go
··· 30 31 // TODO: proper statuses here on early exit 32 func (rp *Repo) AttachArtifact(w http.ResponseWriter, r *http.Request) { 33 - user := rp.oauth.GetUser(r) 34 tagParam := chi.URLParam(r, "tag") 35 f, err := rp.repoResolver.Resolve(r) 36 if err != nil { ··· 75 76 putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 77 Collection: tangled.RepoArtifactNSID, 78 - Repo: user.Did, 79 Rkey: rkey, 80 Record: &lexutil.LexiconTypeDecoder{ 81 Val: &tangled.RepoArtifact{ ··· 104 defer tx.Rollback() 105 106 artifact := models.Artifact{ 107 - Did: user.Did, 108 Rkey: rkey, 109 RepoAt: f.RepoAt(), 110 Tag: tag.Tag.Hash, ··· 220 221 // TODO: proper statuses here on early exit 222 func (rp *Repo) DeleteArtifact(w http.ResponseWriter, r *http.Request) { 223 - user := rp.oauth.GetUser(r) 224 tagParam := chi.URLParam(r, "tag") 225 filename := chi.URLParam(r, "file") 226 f, err := rp.repoResolver.Resolve(r) ··· 251 252 artifact := artifacts[0] 253 254 - if user.Did != artifact.Did { 255 log.Println("user not authorized to delete artifact", err) 256 rp.pages.Notice(w, "remove", "Unauthorized deletion of artifact.") 257 return ··· 259 260 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 261 Collection: tangled.RepoArtifactNSID, 262 - Repo: user.Did, 263 Rkey: artifact.Rkey, 264 }) 265 if err != nil {
··· 30 31 // TODO: proper statuses here on early exit 32 func (rp *Repo) AttachArtifact(w http.ResponseWriter, r *http.Request) { 33 + user := rp.oauth.GetMultiAccountUser(r) 34 tagParam := chi.URLParam(r, "tag") 35 f, err := rp.repoResolver.Resolve(r) 36 if err != nil { ··· 75 76 putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 77 Collection: tangled.RepoArtifactNSID, 78 + Repo: user.Active.Did, 79 Rkey: rkey, 80 Record: &lexutil.LexiconTypeDecoder{ 81 Val: &tangled.RepoArtifact{ ··· 104 defer tx.Rollback() 105 106 artifact := models.Artifact{ 107 + Did: user.Active.Did, 108 Rkey: rkey, 109 RepoAt: f.RepoAt(), 110 Tag: tag.Tag.Hash, ··· 220 221 // TODO: proper statuses here on early exit 222 func (rp *Repo) DeleteArtifact(w http.ResponseWriter, r *http.Request) { 223 + user := rp.oauth.GetMultiAccountUser(r) 224 tagParam := chi.URLParam(r, "tag") 225 filename := chi.URLParam(r, "file") 226 f, err := rp.repoResolver.Resolve(r) ··· 251 252 artifact := artifacts[0] 253 254 + if user.Active.Did != artifact.Did { 255 log.Println("user not authorized to delete artifact", err) 256 rp.pages.Notice(w, "remove", "Unauthorized deletion of artifact.") 257 return ··· 259 260 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 261 Collection: tangled.RepoArtifactNSID, 262 + Repo: user.Active.Did, 263 Rkey: artifact.Rkey, 264 }) 265 if err != nil {
+1 -1
appview/repo/blob.go
··· 76 // Create the blob view 77 blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 78 79 - user := rp.oauth.GetUser(r) 80 81 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 82 LoggedInUser: user,
··· 76 // Create the blob view 77 blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 78 79 + user := rp.oauth.GetMultiAccountUser(r) 80 81 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 82 LoggedInUser: user,
+1 -1
appview/repo/branches.go
··· 43 return 44 } 45 sortBranches(result.Branches) 46 - user := rp.oauth.GetUser(r) 47 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 48 LoggedInUser: user, 49 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
··· 43 return 44 } 45 sortBranches(result.Branches) 46 + user := rp.oauth.GetMultiAccountUser(r) 47 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 48 LoggedInUser: user, 49 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
+2 -2
appview/repo/compare.go
··· 20 func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) { 21 l := rp.logger.With("handler", "RepoCompareNew") 22 23 - user := rp.oauth.GetUser(r) 24 f, err := rp.repoResolver.Resolve(r) 25 if err != nil { 26 l.Error("failed to get repo and knot", "err", err) ··· 101 func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) { 102 l := rp.logger.With("handler", "RepoCompare") 103 104 - user := rp.oauth.GetUser(r) 105 f, err := rp.repoResolver.Resolve(r) 106 if err != nil { 107 l.Error("failed to get repo and knot", "err", err)
··· 20 func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) { 21 l := rp.logger.With("handler", "RepoCompareNew") 22 23 + user := rp.oauth.GetMultiAccountUser(r) 24 f, err := rp.repoResolver.Resolve(r) 25 if err != nil { 26 l.Error("failed to get repo and knot", "err", err) ··· 101 func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) { 102 l := rp.logger.With("handler", "RepoCompare") 103 104 + user := rp.oauth.GetMultiAccountUser(r) 105 f, err := rp.repoResolver.Resolve(r) 106 if err != nil { 107 l.Error("failed to get repo and knot", "err", err)
+3 -3
appview/repo/feed.go
··· 19 ) 20 21 func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string) (*feeds.Feed, error) { 22 - const feedLimitPerType = 100 23 24 - pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, orm.FilterEq("repo_at", repo.RepoAt())) 25 if err != nil { 26 return nil, err 27 } 28 29 issues, err := db.GetIssuesPaginated( 30 rp.db, 31 - pagination.Page{Limit: feedLimitPerType}, 32 orm.FilterEq("repo_at", repo.RepoAt()), 33 ) 34 if err != nil {
··· 19 ) 20 21 func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string) (*feeds.Feed, error) { 22 + feedPagePerType := pagination.Page{Limit: 100} 23 24 + pulls, err := db.GetPullsPaginated(rp.db, feedPagePerType, orm.FilterEq("repo_at", repo.RepoAt())) 25 if err != nil { 26 return nil, err 27 } 28 29 issues, err := db.GetIssuesPaginated( 30 rp.db, 31 + feedPagePerType, 32 orm.FilterEq("repo_at", repo.RepoAt()), 33 ) 34 if err != nil {
+1 -1
appview/repo/index.go
··· 51 Host: host, 52 } 53 54 - user := rp.oauth.GetUser(r) 55 56 // Build index response from multiple XRPC calls 57 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
··· 51 Host: host, 52 } 53 54 + user := rp.oauth.GetMultiAccountUser(r) 55 56 // Build index response from multiple XRPC calls 57 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
+2 -2
appview/repo/log.go
··· 109 } 110 } 111 112 - user := rp.oauth.GetUser(r) 113 114 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 115 if err != nil { ··· 197 l.Error("failed to GetVerifiedCommits", "err", err) 198 } 199 200 - user := rp.oauth.GetUser(r) 201 pipelines, err := getPipelineStatuses(rp.db, f, []string{result.Diff.Commit.This}) 202 if err != nil { 203 l.Error("failed to getPipelineStatuses", "err", err)
··· 109 } 110 } 111 112 + user := rp.oauth.GetMultiAccountUser(r) 113 114 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 115 if err != nil { ··· 197 l.Error("failed to GetVerifiedCommits", "err", err) 198 } 199 200 + user := rp.oauth.GetMultiAccountUser(r) 201 pipelines, err := getPipelineStatuses(rp.db, f, []string{result.Diff.Commit.This}) 202 if err != nil { 203 l.Error("failed to getPipelineStatuses", "err", err)
+1 -1
appview/repo/opengraph.go
··· 237 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 238 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 239 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 240 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 241 if err != nil { 242 log.Printf("dolly silhouette not available (this is ok): %v", err) 243 }
··· 237 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 238 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 239 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 240 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 241 if err != nil { 242 log.Printf("dolly silhouette not available (this is ok): %v", err) 243 }
+34 -34
appview/repo/repo.go
··· 81 82 // modify the spindle configured for this repo 83 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 84 - user := rp.oauth.GetUser(r) 85 l := rp.logger.With("handler", "EditSpindle") 86 - l = l.With("did", user.Did) 87 88 errorId := "operation-error" 89 fail := func(msg string, err error) { ··· 107 108 if !removingSpindle { 109 // ensure that this is a valid spindle for this user 110 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 111 if err != nil { 112 fail("Failed to find spindles. Try again later.", err) 113 return ··· 168 } 169 170 func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { 171 - user := rp.oauth.GetUser(r) 172 l := rp.logger.With("handler", "AddLabel") 173 - l = l.With("did", user.Did) 174 175 f, err := rp.repoResolver.Resolve(r) 176 if err != nil { ··· 216 } 217 218 label := models.LabelDefinition{ 219 - Did: user.Did, 220 Rkey: tid.TID(), 221 Name: name, 222 ValueType: valueType, ··· 327 } 328 329 func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) { 330 - user := rp.oauth.GetUser(r) 331 l := rp.logger.With("handler", "DeleteLabel") 332 - l = l.With("did", user.Did) 333 334 f, err := rp.repoResolver.Resolve(r) 335 if err != nil { ··· 435 } 436 437 func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { 438 - user := rp.oauth.GetUser(r) 439 l := rp.logger.With("handler", "SubscribeLabel") 440 - l = l.With("did", user.Did) 441 442 f, err := rp.repoResolver.Resolve(r) 443 if err != nil { ··· 521 } 522 523 func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { 524 - user := rp.oauth.GetUser(r) 525 l := rp.logger.With("handler", "UnsubscribeLabel") 526 - l = l.With("did", user.Did) 527 528 f, err := rp.repoResolver.Resolve(r) 529 if err != nil { ··· 633 } 634 state := states[subject] 635 636 - user := rp.oauth.GetUser(r) 637 rp.pages.LabelPanel(w, pages.LabelPanelParams{ 638 LoggedInUser: user, 639 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), ··· 681 } 682 state := states[subject] 683 684 - user := rp.oauth.GetUser(r) 685 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ 686 LoggedInUser: user, 687 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), ··· 692 } 693 694 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 695 - user := rp.oauth.GetUser(r) 696 l := rp.logger.With("handler", "AddCollaborator") 697 - l = l.With("did", user.Did) 698 699 f, err := rp.repoResolver.Resolve(r) 700 if err != nil { ··· 723 return 724 } 725 726 - if collaboratorIdent.DID.String() == user.Did { 727 fail("You seem to be adding yourself as a collaborator.", nil) 728 return 729 } ··· 738 } 739 740 // emit a record 741 - currentUser := rp.oauth.GetUser(r) 742 rkey := tid.TID() 743 createdAt := time.Now() 744 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 745 Collection: tangled.RepoCollaboratorNSID, 746 - Repo: currentUser.Did, 747 Rkey: rkey, 748 Record: &lexutil.LexiconTypeDecoder{ 749 Val: &tangled.RepoCollaborator{ ··· 792 } 793 794 err = db.AddCollaborator(tx, models.Collaborator{ 795 - Did: syntax.DID(currentUser.Did), 796 Rkey: rkey, 797 SubjectDid: collaboratorIdent.DID, 798 RepoAt: f.RepoAt(), ··· 822 } 823 824 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 825 - user := rp.oauth.GetUser(r) 826 l := rp.logger.With("handler", "DeleteRepo") 827 828 noticeId := "operation-error" ··· 840 } 841 _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 842 Collection: tangled.RepoNSID, 843 - Repo: user.Did, 844 Rkey: f.Rkey, 845 }) 846 if err != nil { ··· 940 ref := chi.URLParam(r, "ref") 941 ref, _ = url.PathUnescape(ref) 942 943 - user := rp.oauth.GetUser(r) 944 f, err := rp.repoResolver.Resolve(r) 945 if err != nil { 946 l.Error("failed to resolve source repo", "err", err) ··· 969 r.Context(), 970 client, 971 &tangled.RepoForkSync_Input{ 972 - Did: user.Did, 973 Name: f.Name, 974 Source: f.Source, 975 Branch: ref, ··· 988 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 989 l := rp.logger.With("handler", "ForkRepo") 990 991 - user := rp.oauth.GetUser(r) 992 f, err := rp.repoResolver.Resolve(r) 993 if err != nil { 994 l.Error("failed to resolve source repo", "err", err) ··· 997 998 switch r.Method { 999 case http.MethodGet: 1000 - user := rp.oauth.GetUser(r) 1001 - knots, err := rp.enforcer.GetKnotsForUser(user.Did) 1002 if err != nil { 1003 rp.pages.Notice(w, "repo", "Invalid user account.") 1004 return ··· 1020 } 1021 l = l.With("targetKnot", targetKnot) 1022 1023 - ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1024 if err != nil || !ok { 1025 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1026 return ··· 1037 // in the user's account. 1038 existingRepo, err := db.GetRepo( 1039 rp.db, 1040 - orm.FilterEq("did", user.Did), 1041 orm.FilterEq("name", forkName), 1042 ) 1043 if err != nil { ··· 1066 // create an atproto record for this fork 1067 rkey := tid.TID() 1068 repo := &models.Repo{ 1069 - Did: user.Did, 1070 Name: forkName, 1071 Knot: targetKnot, 1072 Rkey: rkey, ··· 1086 1087 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 1088 Collection: tangled.RepoNSID, 1089 - Repo: user.Did, 1090 Rkey: rkey, 1091 Record: &lexutil.LexiconTypeDecoder{ 1092 Val: &record, ··· 1165 } 1166 1167 // acls 1168 - p, _ := securejoin.SecureJoin(user.Did, forkName) 1169 - err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1170 if err != nil { 1171 l.Error("failed to add ACLs", "err", err) 1172 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1191 aturi = "" 1192 1193 rp.notifier.NewRepo(r.Context(), repo) 1194 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName)) 1195 } 1196 } 1197
··· 81 82 // modify the spindle configured for this repo 83 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 84 + user := rp.oauth.GetMultiAccountUser(r) 85 l := rp.logger.With("handler", "EditSpindle") 86 + l = l.With("did", user.Active.Did) 87 88 errorId := "operation-error" 89 fail := func(msg string, err error) { ··· 107 108 if !removingSpindle { 109 // ensure that this is a valid spindle for this user 110 + validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Active.Did) 111 if err != nil { 112 fail("Failed to find spindles. Try again later.", err) 113 return ··· 168 } 169 170 func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { 171 + user := rp.oauth.GetMultiAccountUser(r) 172 l := rp.logger.With("handler", "AddLabel") 173 + l = l.With("did", user.Active.Did) 174 175 f, err := rp.repoResolver.Resolve(r) 176 if err != nil { ··· 216 } 217 218 label := models.LabelDefinition{ 219 + Did: user.Active.Did, 220 Rkey: tid.TID(), 221 Name: name, 222 ValueType: valueType, ··· 327 } 328 329 func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) { 330 + user := rp.oauth.GetMultiAccountUser(r) 331 l := rp.logger.With("handler", "DeleteLabel") 332 + l = l.With("did", user.Active.Did) 333 334 f, err := rp.repoResolver.Resolve(r) 335 if err != nil { ··· 435 } 436 437 func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { 438 + user := rp.oauth.GetMultiAccountUser(r) 439 l := rp.logger.With("handler", "SubscribeLabel") 440 + l = l.With("did", user.Active.Did) 441 442 f, err := rp.repoResolver.Resolve(r) 443 if err != nil { ··· 521 } 522 523 func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { 524 + user := rp.oauth.GetMultiAccountUser(r) 525 l := rp.logger.With("handler", "UnsubscribeLabel") 526 + l = l.With("did", user.Active.Did) 527 528 f, err := rp.repoResolver.Resolve(r) 529 if err != nil { ··· 633 } 634 state := states[subject] 635 636 + user := rp.oauth.GetMultiAccountUser(r) 637 rp.pages.LabelPanel(w, pages.LabelPanelParams{ 638 LoggedInUser: user, 639 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), ··· 681 } 682 state := states[subject] 683 684 + user := rp.oauth.GetMultiAccountUser(r) 685 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ 686 LoggedInUser: user, 687 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), ··· 692 } 693 694 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 695 + user := rp.oauth.GetMultiAccountUser(r) 696 l := rp.logger.With("handler", "AddCollaborator") 697 + l = l.With("did", user.Active.Did) 698 699 f, err := rp.repoResolver.Resolve(r) 700 if err != nil { ··· 723 return 724 } 725 726 + if collaboratorIdent.DID.String() == user.Active.Did { 727 fail("You seem to be adding yourself as a collaborator.", nil) 728 return 729 } ··· 738 } 739 740 // emit a record 741 + currentUser := rp.oauth.GetMultiAccountUser(r) 742 rkey := tid.TID() 743 createdAt := time.Now() 744 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 745 Collection: tangled.RepoCollaboratorNSID, 746 + Repo: currentUser.Active.Did, 747 Rkey: rkey, 748 Record: &lexutil.LexiconTypeDecoder{ 749 Val: &tangled.RepoCollaborator{ ··· 792 } 793 794 err = db.AddCollaborator(tx, models.Collaborator{ 795 + Did: syntax.DID(currentUser.Active.Did), 796 Rkey: rkey, 797 SubjectDid: collaboratorIdent.DID, 798 RepoAt: f.RepoAt(), ··· 822 } 823 824 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 825 + user := rp.oauth.GetMultiAccountUser(r) 826 l := rp.logger.With("handler", "DeleteRepo") 827 828 noticeId := "operation-error" ··· 840 } 841 _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 842 Collection: tangled.RepoNSID, 843 + Repo: user.Active.Did, 844 Rkey: f.Rkey, 845 }) 846 if err != nil { ··· 940 ref := chi.URLParam(r, "ref") 941 ref, _ = url.PathUnescape(ref) 942 943 + user := rp.oauth.GetMultiAccountUser(r) 944 f, err := rp.repoResolver.Resolve(r) 945 if err != nil { 946 l.Error("failed to resolve source repo", "err", err) ··· 969 r.Context(), 970 client, 971 &tangled.RepoForkSync_Input{ 972 + Did: user.Active.Did, 973 Name: f.Name, 974 Source: f.Source, 975 Branch: ref, ··· 988 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 989 l := rp.logger.With("handler", "ForkRepo") 990 991 + user := rp.oauth.GetMultiAccountUser(r) 992 f, err := rp.repoResolver.Resolve(r) 993 if err != nil { 994 l.Error("failed to resolve source repo", "err", err) ··· 997 998 switch r.Method { 999 case http.MethodGet: 1000 + user := rp.oauth.GetMultiAccountUser(r) 1001 + knots, err := rp.enforcer.GetKnotsForUser(user.Active.Did) 1002 if err != nil { 1003 rp.pages.Notice(w, "repo", "Invalid user account.") 1004 return ··· 1020 } 1021 l = l.With("targetKnot", targetKnot) 1022 1023 + ok, err := rp.enforcer.E.Enforce(user.Active.Did, targetKnot, targetKnot, "repo:create") 1024 if err != nil || !ok { 1025 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1026 return ··· 1037 // in the user's account. 1038 existingRepo, err := db.GetRepo( 1039 rp.db, 1040 + orm.FilterEq("did", user.Active.Did), 1041 orm.FilterEq("name", forkName), 1042 ) 1043 if err != nil { ··· 1066 // create an atproto record for this fork 1067 rkey := tid.TID() 1068 repo := &models.Repo{ 1069 + Did: user.Active.Did, 1070 Name: forkName, 1071 Knot: targetKnot, 1072 Rkey: rkey, ··· 1086 1087 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 1088 Collection: tangled.RepoNSID, 1089 + Repo: user.Active.Did, 1090 Rkey: rkey, 1091 Record: &lexutil.LexiconTypeDecoder{ 1092 Val: &record, ··· 1165 } 1166 1167 // acls 1168 + p, _ := securejoin.SecureJoin(user.Active.Did, forkName) 1169 + err = rp.enforcer.AddRepo(user.Active.Did, targetKnot, p) 1170 if err != nil { 1171 l.Error("failed to add ACLs", "err", err) 1172 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1191 aturi = "" 1192 1193 rp.notifier.NewRepo(r.Context(), repo) 1194 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Active.Did, forkName)) 1195 } 1196 } 1197
+5 -5
appview/repo/settings.go
··· 79 } 80 81 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 82 - user := rp.oauth.GetUser(r) 83 l := rp.logger.With("handler", "Secrets") 84 - l = l.With("did", user.Did) 85 86 f, err := rp.repoResolver.Resolve(r) 87 if err != nil { ··· 185 l := rp.logger.With("handler", "generalSettings") 186 187 f, err := rp.repoResolver.Resolve(r) 188 - user := rp.oauth.GetUser(r) 189 190 scheme := "http" 191 if !rp.config.Core.Dev { ··· 271 l := rp.logger.With("handler", "accessSettings") 272 273 f, err := rp.repoResolver.Resolve(r) 274 - user := rp.oauth.GetUser(r) 275 276 collaborators, err := func(repo *models.Repo) ([]pages.Collaborator, error) { 277 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(repo.DidSlashRepo(), repo.Knot) ··· 318 l := rp.logger.With("handler", "pipelineSettings") 319 320 f, err := rp.repoResolver.Resolve(r) 321 - user := rp.oauth.GetUser(r) 322 323 // all spindles that the repo owner is a member of 324 spindles, err := rp.enforcer.GetSpindlesForUser(f.Did)
··· 79 } 80 81 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 82 + user := rp.oauth.GetMultiAccountUser(r) 83 l := rp.logger.With("handler", "Secrets") 84 + l = l.With("did", user.Active.Did) 85 86 f, err := rp.repoResolver.Resolve(r) 87 if err != nil { ··· 185 l := rp.logger.With("handler", "generalSettings") 186 187 f, err := rp.repoResolver.Resolve(r) 188 + user := rp.oauth.GetMultiAccountUser(r) 189 190 scheme := "http" 191 if !rp.config.Core.Dev { ··· 271 l := rp.logger.With("handler", "accessSettings") 272 273 f, err := rp.repoResolver.Resolve(r) 274 + user := rp.oauth.GetMultiAccountUser(r) 275 276 collaborators, err := func(repo *models.Repo) ([]pages.Collaborator, error) { 277 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(repo.DidSlashRepo(), repo.Knot) ··· 318 l := rp.logger.With("handler", "pipelineSettings") 319 320 f, err := rp.repoResolver.Resolve(r) 321 + user := rp.oauth.GetMultiAccountUser(r) 322 323 // all spindles that the repo owner is a member of 324 spindles, err := rp.enforcer.GetSpindlesForUser(f.Did)
+1 -1
appview/repo/tags.go
··· 69 danglingArtifacts = append(danglingArtifacts, a) 70 } 71 } 72 - user := rp.oauth.GetUser(r) 73 rp.pages.RepoTags(w, pages.RepoTagsParams{ 74 LoggedInUser: user, 75 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
··· 69 danglingArtifacts = append(danglingArtifacts, a) 70 } 71 } 72 + user := rp.oauth.GetMultiAccountUser(r) 73 rp.pages.RepoTags(w, pages.RepoTagsParams{ 74 LoggedInUser: user, 75 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
+1 -1
appview/repo/tree.go
··· 88 http.Redirect(w, r, redirectTo, http.StatusFound) 89 return 90 } 91 - user := rp.oauth.GetUser(r) 92 var breadcrumbs [][]string 93 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 94 if treePath != "" {
··· 88 http.Redirect(w, r, redirectTo, http.StatusFound) 89 return 90 } 91 + user := rp.oauth.GetMultiAccountUser(r) 92 var breadcrumbs [][]string 93 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 94 if treePath != "" {
+30 -5
appview/reporesolver/resolver.go
··· 55 // 2. [x] remove `rr`, `CurrentDir`, `Ref` fields from `ResolvedRepo` 56 // 3. [x] remove `ResolvedRepo` 57 // 4. [ ] replace reporesolver to reposervice 58 - func (rr *RepoResolver) GetRepoInfo(r *http.Request, user *oauth.User) repoinfo.RepoInfo { 59 ownerId, ook := r.Context().Value("resolvedId").(identity.Identity) 60 repo, rok := r.Context().Value("repo").(*models.Repo) 61 if !ook || !rok { ··· 63 } 64 65 // get dir/ref 66 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 67 ref := chi.URLParam(r, "ref") 68 69 repoAt := repo.RepoAt() 70 isStarred := false 71 roles := repoinfo.RolesInRepo{} 72 - if user != nil { 73 - isStarred = db.GetStarStatus(rr.execer, user.Did, repoAt) 74 - roles.Roles = rr.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 75 } 76 77 stats := repo.RepoStats ··· 130 } 131 132 return repoInfo 133 } 134 135 // extractPathAfterRef gets the actual repository path
··· 55 // 2. [x] remove `rr`, `CurrentDir`, `Ref` fields from `ResolvedRepo` 56 // 3. [x] remove `ResolvedRepo` 57 // 4. [ ] replace reporesolver to reposervice 58 + func (rr *RepoResolver) GetRepoInfo(r *http.Request, user *oauth.MultiAccountUser) repoinfo.RepoInfo { 59 ownerId, ook := r.Context().Value("resolvedId").(identity.Identity) 60 repo, rok := r.Context().Value("repo").(*models.Repo) 61 if !ook || !rok { ··· 63 } 64 65 // get dir/ref 66 + currentDir := extractCurrentDir(r.URL.EscapedPath()) 67 ref := chi.URLParam(r, "ref") 68 69 repoAt := repo.RepoAt() 70 isStarred := false 71 roles := repoinfo.RolesInRepo{} 72 + if user != nil && user.Active != nil { 73 + isStarred = db.GetStarStatus(rr.execer, user.Active.Did, repoAt) 74 + roles.Roles = rr.enforcer.GetPermissionsInRepo(user.Active.Did, repo.Knot, repo.DidSlashRepo()) 75 } 76 77 stats := repo.RepoStats ··· 130 } 131 132 return repoInfo 133 + } 134 + 135 + // extractCurrentDir gets the current directory for markdown link resolution. 136 + // for blob paths, returns the parent dir. for tree paths, returns the path itself. 137 + // 138 + // /@user/repo/blob/main/docs/README.md => docs 139 + // /@user/repo/tree/main/docs => docs 140 + func extractCurrentDir(fullPath string) string { 141 + fullPath = strings.TrimPrefix(fullPath, "/") 142 + 143 + blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`) 144 + if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 { 145 + return path.Dir(matches[1]) 146 + } 147 + 148 + treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`) 149 + if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 { 150 + dir := strings.TrimSuffix(matches[1], "/") 151 + if dir == "" { 152 + return "." 153 + } 154 + return dir 155 + } 156 + 157 + return "." 158 } 159 160 // extractPathAfterRef gets the actual repository path
+22
appview/reporesolver/resolver_test.go
···
··· 1 + package reporesolver 2 + 3 + import "testing" 4 + 5 + func TestExtractCurrentDir(t *testing.T) { 6 + tests := []struct { 7 + path string 8 + want string 9 + }{ 10 + {"/@user/repo/blob/main/docs/README.md", "docs"}, 11 + {"/@user/repo/blob/main/README.md", "."}, 12 + {"/@user/repo/tree/main/docs", "docs"}, 13 + {"/@user/repo/tree/main/docs/", "docs"}, 14 + {"/@user/repo/tree/main", "."}, 15 + } 16 + 17 + for _, tt := range tests { 18 + if got := extractCurrentDir(tt.path); got != tt.want { 19 + t.Errorf("extractCurrentDir(%q) = %q, want %q", tt.path, got, tt.want) 20 + } 21 + } 22 + }
+6 -6
appview/settings/settings.go
··· 81 } 82 83 func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 84 - user := s.OAuth.GetUser(r) 85 86 s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 87 LoggedInUser: user, ··· 91 } 92 93 func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) { 94 - user := s.OAuth.GetUser(r) 95 did := s.OAuth.GetDid(r) 96 97 prefs, err := db.GetNotificationPreference(s.Db, did) ··· 137 } 138 139 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 140 - user := s.OAuth.GetUser(r) 141 - pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) 142 if err != nil { 143 log.Println(err) 144 } ··· 152 } 153 154 func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) { 155 - user := s.OAuth.GetUser(r) 156 - emails, err := db.GetAllEmails(s.Db, user.Did) 157 if err != nil { 158 log.Println(err) 159 }
··· 81 } 82 83 func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 84 + user := s.OAuth.GetMultiAccountUser(r) 85 86 s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 87 LoggedInUser: user, ··· 91 } 92 93 func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) { 94 + user := s.OAuth.GetMultiAccountUser(r) 95 did := s.OAuth.GetDid(r) 96 97 prefs, err := db.GetNotificationPreference(s.Db, did) ··· 137 } 138 139 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 140 + user := s.OAuth.GetMultiAccountUser(r) 141 + pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Active.Did) 142 if err != nil { 143 log.Println(err) 144 } ··· 152 } 153 154 func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) { 155 + user := s.OAuth.GetMultiAccountUser(r) 156 + emails, err := db.GetAllEmails(s.Db, user.Active.Did) 157 if err != nil { 158 log.Println(err) 159 }
+41 -46
appview/spindles/spindles.go
··· 69 } 70 71 func (s *Spindles) spindles(w http.ResponseWriter, r *http.Request) { 72 - user := s.OAuth.GetUser(r) 73 all, err := db.GetSpindles( 74 s.Db, 75 - orm.FilterEq("owner", user.Did), 76 ) 77 if err != nil { 78 s.Logger.Error("failed to fetch spindles", "err", err) ··· 91 func (s *Spindles) dashboard(w http.ResponseWriter, r *http.Request) { 92 l := s.Logger.With("handler", "dashboard") 93 94 - user := s.OAuth.GetUser(r) 95 - l = l.With("user", user.Did) 96 97 instance := chi.URLParam(r, "instance") 98 if instance == "" { ··· 103 spindles, err := db.GetSpindles( 104 s.Db, 105 orm.FilterEq("instance", instance), 106 - orm.FilterEq("owner", user.Did), 107 orm.FilterIsNot("verified", "null"), 108 ) 109 if err != nil || len(spindles) != 1 { ··· 155 // 156 // if the spindle is not up yet, the user is free to retry verification at a later point 157 func (s *Spindles) register(w http.ResponseWriter, r *http.Request) { 158 - user := s.OAuth.GetUser(r) 159 l := s.Logger.With("handler", "register") 160 161 noticeId := "register-error" ··· 176 return 177 } 178 l = l.With("instance", instance) 179 - l = l.With("user", user.Did) 180 181 tx, err := s.Db.Begin() 182 if err != nil { ··· 190 }() 191 192 err = db.AddSpindle(tx, models.Spindle{ 193 - Owner: syntax.DID(user.Did), 194 Instance: instance, 195 }) 196 if err != nil { ··· 214 return 215 } 216 217 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 218 var exCid *string 219 if ex != nil { 220 exCid = ex.Cid ··· 223 // re-announce by registering under same rkey 224 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 225 Collection: tangled.SpindleNSID, 226 - Repo: user.Did, 227 Rkey: instance, 228 Record: &lexutil.LexiconTypeDecoder{ 229 Val: &tangled.Spindle{ ··· 254 } 255 256 // begin verification 257 - err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 258 if err != nil { 259 l.Error("verification failed", "err", err) 260 s.Pages.HxRefresh(w) 261 return 262 } 263 264 - _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 265 if err != nil { 266 l.Error("failed to mark verified", "err", err) 267 s.Pages.HxRefresh(w) ··· 273 } 274 275 func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { 276 - user := s.OAuth.GetUser(r) 277 l := s.Logger.With("handler", "delete") 278 279 noticeId := "operation-error" ··· 291 292 spindles, err := db.GetSpindles( 293 s.Db, 294 - orm.FilterEq("owner", user.Did), 295 orm.FilterEq("instance", instance), 296 ) 297 if err != nil || len(spindles) != 1 { ··· 300 return 301 } 302 303 - if string(spindles[0].Owner) != user.Did { 304 - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 305 s.Pages.Notice(w, noticeId, "Failed to delete spindle, unauthorized deletion attempt.") 306 return 307 } ··· 320 // remove spindle members first 321 err = db.RemoveSpindleMember( 322 tx, 323 - orm.FilterEq("did", user.Did), 324 orm.FilterEq("instance", instance), 325 ) 326 if err != nil { ··· 331 332 err = db.DeleteSpindle( 333 tx, 334 - orm.FilterEq("owner", user.Did), 335 orm.FilterEq("instance", instance), 336 ) 337 if err != nil { ··· 359 360 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 361 Collection: tangled.SpindleNSID, 362 - Repo: user.Did, 363 Rkey: instance, 364 }) 365 if err != nil { ··· 391 } 392 393 func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) { 394 - user := s.OAuth.GetUser(r) 395 l := s.Logger.With("handler", "retry") 396 397 noticeId := "operation-error" ··· 407 return 408 } 409 l = l.With("instance", instance) 410 - l = l.With("user", user.Did) 411 412 spindles, err := db.GetSpindles( 413 s.Db, 414 - orm.FilterEq("owner", user.Did), 415 orm.FilterEq("instance", instance), 416 ) 417 if err != nil || len(spindles) != 1 { ··· 420 return 421 } 422 423 - if string(spindles[0].Owner) != user.Did { 424 - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 425 s.Pages.Notice(w, noticeId, "Failed to verify spindle, unauthorized verification attempt.") 426 return 427 } 428 429 // begin verification 430 - err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 431 if err != nil { 432 l.Error("verification failed", "err", err) 433 ··· 445 return 446 } 447 448 - rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 449 if err != nil { 450 l.Error("failed to mark verified", "err", err) 451 s.Pages.Notice(w, noticeId, err.Error()) ··· 473 } 474 475 func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { 476 - user := s.OAuth.GetUser(r) 477 l := s.Logger.With("handler", "addMember") 478 479 instance := chi.URLParam(r, "instance") ··· 483 return 484 } 485 l = l.With("instance", instance) 486 - l = l.With("user", user.Did) 487 488 spindles, err := db.GetSpindles( 489 s.Db, 490 - orm.FilterEq("owner", user.Did), 491 orm.FilterEq("instance", instance), 492 ) 493 if err != nil || len(spindles) != 1 { ··· 502 s.Pages.Notice(w, noticeId, defaultErr) 503 } 504 505 - if string(spindles[0].Owner) != user.Did { 506 - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 507 s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 508 return 509 } ··· 552 553 // add member to db 554 if err = db.AddSpindleMember(tx, models.SpindleMember{ 555 - Did: syntax.DID(user.Did), 556 Rkey: rkey, 557 Instance: instance, 558 Subject: memberId.DID, ··· 570 571 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 572 Collection: tangled.SpindleMemberNSID, 573 - Repo: user.Did, 574 Rkey: rkey, 575 Record: &lexutil.LexiconTypeDecoder{ 576 Val: &tangled.SpindleMember{ ··· 603 } 604 605 func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { 606 - user := s.OAuth.GetUser(r) 607 l := s.Logger.With("handler", "removeMember") 608 609 noticeId := "operation-error" ··· 619 return 620 } 621 l = l.With("instance", instance) 622 - l = l.With("user", user.Did) 623 624 spindles, err := db.GetSpindles( 625 s.Db, 626 - orm.FilterEq("owner", user.Did), 627 orm.FilterEq("instance", instance), 628 ) 629 if err != nil || len(spindles) != 1 { ··· 632 return 633 } 634 635 - if string(spindles[0].Owner) != user.Did { 636 - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 637 s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 638 return 639 } ··· 653 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 654 return 655 } 656 - if memberId.Handle.IsInvalidHandle() { 657 - l.Error("failed to resolve member identity to handle") 658 - s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 659 - return 660 - } 661 662 tx, err := s.Db.Begin() 663 if err != nil { ··· 673 // get the record from the DB first: 674 members, err := db.GetSpindleMembers( 675 s.Db, 676 - orm.FilterEq("did", user.Did), 677 orm.FilterEq("instance", instance), 678 orm.FilterEq("subject", memberId.DID), 679 ) ··· 686 // remove from db 687 if err = db.RemoveSpindleMember( 688 tx, 689 - orm.FilterEq("did", user.Did), 690 orm.FilterEq("instance", instance), 691 orm.FilterEq("subject", memberId.DID), 692 ); err != nil { ··· 712 // remove from pds 713 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 714 Collection: tangled.SpindleMemberNSID, 715 - Repo: user.Did, 716 Rkey: members[0].Rkey, 717 }) 718 if err != nil {
··· 69 } 70 71 func (s *Spindles) spindles(w http.ResponseWriter, r *http.Request) { 72 + user := s.OAuth.GetMultiAccountUser(r) 73 all, err := db.GetSpindles( 74 s.Db, 75 + orm.FilterEq("owner", user.Active.Did), 76 ) 77 if err != nil { 78 s.Logger.Error("failed to fetch spindles", "err", err) ··· 91 func (s *Spindles) dashboard(w http.ResponseWriter, r *http.Request) { 92 l := s.Logger.With("handler", "dashboard") 93 94 + user := s.OAuth.GetMultiAccountUser(r) 95 + l = l.With("user", user.Active.Did) 96 97 instance := chi.URLParam(r, "instance") 98 if instance == "" { ··· 103 spindles, err := db.GetSpindles( 104 s.Db, 105 orm.FilterEq("instance", instance), 106 + orm.FilterEq("owner", user.Active.Did), 107 orm.FilterIsNot("verified", "null"), 108 ) 109 if err != nil || len(spindles) != 1 { ··· 155 // 156 // if the spindle is not up yet, the user is free to retry verification at a later point 157 func (s *Spindles) register(w http.ResponseWriter, r *http.Request) { 158 + user := s.OAuth.GetMultiAccountUser(r) 159 l := s.Logger.With("handler", "register") 160 161 noticeId := "register-error" ··· 176 return 177 } 178 l = l.With("instance", instance) 179 + l = l.With("user", user.Active.Did) 180 181 tx, err := s.Db.Begin() 182 if err != nil { ··· 190 }() 191 192 err = db.AddSpindle(tx, models.Spindle{ 193 + Owner: syntax.DID(user.Active.Did), 194 Instance: instance, 195 }) 196 if err != nil { ··· 214 return 215 } 216 217 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Active.Did, instance) 218 var exCid *string 219 if ex != nil { 220 exCid = ex.Cid ··· 223 // re-announce by registering under same rkey 224 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 225 Collection: tangled.SpindleNSID, 226 + Repo: user.Active.Did, 227 Rkey: instance, 228 Record: &lexutil.LexiconTypeDecoder{ 229 Val: &tangled.Spindle{ ··· 254 } 255 256 // begin verification 257 + err = serververify.RunVerification(r.Context(), instance, user.Active.Did, s.Config.Core.Dev) 258 if err != nil { 259 l.Error("verification failed", "err", err) 260 s.Pages.HxRefresh(w) 261 return 262 } 263 264 + _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Active.Did) 265 if err != nil { 266 l.Error("failed to mark verified", "err", err) 267 s.Pages.HxRefresh(w) ··· 273 } 274 275 func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { 276 + user := s.OAuth.GetMultiAccountUser(r) 277 l := s.Logger.With("handler", "delete") 278 279 noticeId := "operation-error" ··· 291 292 spindles, err := db.GetSpindles( 293 s.Db, 294 + orm.FilterEq("owner", user.Active.Did), 295 orm.FilterEq("instance", instance), 296 ) 297 if err != nil || len(spindles) != 1 { ··· 300 return 301 } 302 303 + if string(spindles[0].Owner) != user.Active.Did { 304 + l.Error("unauthorized", "user", user.Active.Did, "owner", spindles[0].Owner) 305 s.Pages.Notice(w, noticeId, "Failed to delete spindle, unauthorized deletion attempt.") 306 return 307 } ··· 320 // remove spindle members first 321 err = db.RemoveSpindleMember( 322 tx, 323 + orm.FilterEq("did", user.Active.Did), 324 orm.FilterEq("instance", instance), 325 ) 326 if err != nil { ··· 331 332 err = db.DeleteSpindle( 333 tx, 334 + orm.FilterEq("owner", user.Active.Did), 335 orm.FilterEq("instance", instance), 336 ) 337 if err != nil { ··· 359 360 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 361 Collection: tangled.SpindleNSID, 362 + Repo: user.Active.Did, 363 Rkey: instance, 364 }) 365 if err != nil { ··· 391 } 392 393 func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) { 394 + user := s.OAuth.GetMultiAccountUser(r) 395 l := s.Logger.With("handler", "retry") 396 397 noticeId := "operation-error" ··· 407 return 408 } 409 l = l.With("instance", instance) 410 + l = l.With("user", user.Active.Did) 411 412 spindles, err := db.GetSpindles( 413 s.Db, 414 + orm.FilterEq("owner", user.Active.Did), 415 orm.FilterEq("instance", instance), 416 ) 417 if err != nil || len(spindles) != 1 { ··· 420 return 421 } 422 423 + if string(spindles[0].Owner) != user.Active.Did { 424 + l.Error("unauthorized", "user", user.Active.Did, "owner", spindles[0].Owner) 425 s.Pages.Notice(w, noticeId, "Failed to verify spindle, unauthorized verification attempt.") 426 return 427 } 428 429 // begin verification 430 + err = serververify.RunVerification(r.Context(), instance, user.Active.Did, s.Config.Core.Dev) 431 if err != nil { 432 l.Error("verification failed", "err", err) 433 ··· 445 return 446 } 447 448 + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Active.Did) 449 if err != nil { 450 l.Error("failed to mark verified", "err", err) 451 s.Pages.Notice(w, noticeId, err.Error()) ··· 473 } 474 475 func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { 476 + user := s.OAuth.GetMultiAccountUser(r) 477 l := s.Logger.With("handler", "addMember") 478 479 instance := chi.URLParam(r, "instance") ··· 483 return 484 } 485 l = l.With("instance", instance) 486 + l = l.With("user", user.Active.Did) 487 488 spindles, err := db.GetSpindles( 489 s.Db, 490 + orm.FilterEq("owner", user.Active.Did), 491 orm.FilterEq("instance", instance), 492 ) 493 if err != nil || len(spindles) != 1 { ··· 502 s.Pages.Notice(w, noticeId, defaultErr) 503 } 504 505 + if string(spindles[0].Owner) != user.Active.Did { 506 + l.Error("unauthorized", "user", user.Active.Did, "owner", spindles[0].Owner) 507 s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 508 return 509 } ··· 552 553 // add member to db 554 if err = db.AddSpindleMember(tx, models.SpindleMember{ 555 + Did: syntax.DID(user.Active.Did), 556 Rkey: rkey, 557 Instance: instance, 558 Subject: memberId.DID, ··· 570 571 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 572 Collection: tangled.SpindleMemberNSID, 573 + Repo: user.Active.Did, 574 Rkey: rkey, 575 Record: &lexutil.LexiconTypeDecoder{ 576 Val: &tangled.SpindleMember{ ··· 603 } 604 605 func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { 606 + user := s.OAuth.GetMultiAccountUser(r) 607 l := s.Logger.With("handler", "removeMember") 608 609 noticeId := "operation-error" ··· 619 return 620 } 621 l = l.With("instance", instance) 622 + l = l.With("user", user.Active.Did) 623 624 spindles, err := db.GetSpindles( 625 s.Db, 626 + orm.FilterEq("owner", user.Active.Did), 627 orm.FilterEq("instance", instance), 628 ) 629 if err != nil || len(spindles) != 1 { ··· 632 return 633 } 634 635 + if string(spindles[0].Owner) != user.Active.Did { 636 + l.Error("unauthorized", "user", user.Active.Did, "owner", spindles[0].Owner) 637 s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 638 return 639 } ··· 653 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 654 return 655 } 656 657 tx, err := s.Db.Begin() 658 if err != nil { ··· 668 // get the record from the DB first: 669 members, err := db.GetSpindleMembers( 670 s.Db, 671 + orm.FilterEq("did", user.Active.Did), 672 orm.FilterEq("instance", instance), 673 orm.FilterEq("subject", memberId.DID), 674 ) ··· 681 // remove from db 682 if err = db.RemoveSpindleMember( 683 tx, 684 + orm.FilterEq("did", user.Active.Did), 685 orm.FilterEq("instance", instance), 686 orm.FilterEq("subject", memberId.DID), 687 ); err != nil { ··· 707 // remove from pds 708 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 709 Collection: tangled.SpindleMemberNSID, 710 + Repo: user.Active.Did, 711 Rkey: members[0].Rkey, 712 }) 713 if err != nil {
+83
appview/state/accounts.go
···
··· 1 + package state 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi/v5" 7 + ) 8 + 9 + func (s *State) SwitchAccount(w http.ResponseWriter, r *http.Request) { 10 + l := s.logger.With("handler", "SwitchAccount") 11 + 12 + if err := r.ParseForm(); err != nil { 13 + l.Error("failed to parse form", "err", err) 14 + http.Error(w, "invalid request", http.StatusBadRequest) 15 + return 16 + } 17 + 18 + did := r.FormValue("did") 19 + if did == "" { 20 + http.Error(w, "missing did", http.StatusBadRequest) 21 + return 22 + } 23 + 24 + if err := s.oauth.SwitchAccount(w, r, did); err != nil { 25 + l.Error("failed to switch account", "err", err) 26 + s.pages.HxRedirect(w, "/login?error=session") 27 + return 28 + } 29 + 30 + l.Info("switched account", "did", did) 31 + s.pages.HxRedirect(w, "/") 32 + } 33 + 34 + func (s *State) RemoveAccount(w http.ResponseWriter, r *http.Request) { 35 + l := s.logger.With("handler", "RemoveAccount") 36 + 37 + did := chi.URLParam(r, "did") 38 + if did == "" { 39 + http.Error(w, "missing did", http.StatusBadRequest) 40 + return 41 + } 42 + 43 + currentUser := s.oauth.GetMultiAccountUser(r) 44 + isCurrentAccount := currentUser != nil && currentUser.Active.Did == did 45 + 46 + var remainingAccounts []string 47 + if currentUser != nil { 48 + for _, acc := range currentUser.Accounts { 49 + if acc.Did != did { 50 + remainingAccounts = append(remainingAccounts, acc.Did) 51 + } 52 + } 53 + } 54 + 55 + if err := s.oauth.RemoveAccount(w, r, did); err != nil { 56 + l.Error("failed to remove account", "err", err) 57 + http.Error(w, "failed to remove account", http.StatusInternalServerError) 58 + return 59 + } 60 + 61 + l.Info("removed account", "did", did) 62 + 63 + if isCurrentAccount { 64 + if len(remainingAccounts) > 0 { 65 + nextDid := remainingAccounts[0] 66 + if err := s.oauth.SwitchAccount(w, r, nextDid); err != nil { 67 + l.Error("failed to switch to next account", "err", err) 68 + s.pages.HxRedirect(w, "/login") 69 + return 70 + } 71 + s.pages.HxRefresh(w) 72 + return 73 + } 74 + 75 + if err := s.oauth.DeleteSession(w, r); err != nil { 76 + l.Error("failed to delete session", "err", err) 77 + } 78 + s.pages.HxRedirect(w, "/login") 79 + return 80 + } 81 + 82 + s.pages.HxRefresh(w) 83 + }
+23 -11
appview/state/follow.go
··· 15 ) 16 17 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { 18 - currentUser := s.oauth.GetUser(r) 19 20 subject := r.URL.Query().Get("subject") 21 if subject == "" { ··· 29 return 30 } 31 32 - if currentUser.Did == subjectIdent.DID.String() { 33 log.Println("cant follow or unfollow yourself") 34 return 35 } ··· 46 rkey := tid.TID() 47 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 48 Collection: tangled.GraphFollowNSID, 49 - Repo: currentUser.Did, 50 Rkey: rkey, 51 Record: &lexutil.LexiconTypeDecoder{ 52 Val: &tangled.GraphFollow{ ··· 62 log.Println("created atproto record: ", resp.Uri) 63 64 follow := &models.Follow{ 65 - UserDid: currentUser.Did, 66 SubjectDid: subjectIdent.DID.String(), 67 Rkey: rkey, 68 } ··· 75 76 s.notifier.NewFollow(r.Context(), follow) 77 78 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 79 - UserDid: subjectIdent.DID.String(), 80 - FollowStatus: models.IsFollowing, 81 }) 82 83 return 84 case http.MethodDelete: 85 // find the record in the db 86 - follow, err := db.GetFollow(s.db, currentUser.Did, subjectIdent.DID.String()) 87 if err != nil { 88 log.Println("failed to get follow relationship") 89 return ··· 91 92 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 93 Collection: tangled.GraphFollowNSID, 94 - Repo: currentUser.Did, 95 Rkey: follow.Rkey, 96 }) 97 ··· 100 return 101 } 102 103 - err = db.DeleteFollowByRkey(s.db, currentUser.Did, follow.Rkey) 104 if err != nil { 105 log.Println("failed to delete follow from DB") 106 // this is not an issue, the firehose event might have already done this 107 } 108 109 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 110 - UserDid: subjectIdent.DID.String(), 111 - FollowStatus: models.IsNotFollowing, 112 }) 113 114 s.notifier.DeleteFollow(r.Context(), follow)
··· 15 ) 16 17 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { 18 + currentUser := s.oauth.GetMultiAccountUser(r) 19 20 subject := r.URL.Query().Get("subject") 21 if subject == "" { ··· 29 return 30 } 31 32 + if currentUser.Active.Did == subjectIdent.DID.String() { 33 log.Println("cant follow or unfollow yourself") 34 return 35 } ··· 46 rkey := tid.TID() 47 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 48 Collection: tangled.GraphFollowNSID, 49 + Repo: currentUser.Active.Did, 50 Rkey: rkey, 51 Record: &lexutil.LexiconTypeDecoder{ 52 Val: &tangled.GraphFollow{ ··· 62 log.Println("created atproto record: ", resp.Uri) 63 64 follow := &models.Follow{ 65 + UserDid: currentUser.Active.Did, 66 SubjectDid: subjectIdent.DID.String(), 67 Rkey: rkey, 68 } ··· 75 76 s.notifier.NewFollow(r.Context(), follow) 77 78 + followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) 79 + if err != nil { 80 + log.Println("failed to get follow stats", err) 81 + } 82 + 83 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 84 + UserDid: subjectIdent.DID.String(), 85 + FollowStatus: models.IsFollowing, 86 + FollowersCount: followStats.Followers, 87 }) 88 89 return 90 case http.MethodDelete: 91 // find the record in the db 92 + follow, err := db.GetFollow(s.db, currentUser.Active.Did, subjectIdent.DID.String()) 93 if err != nil { 94 log.Println("failed to get follow relationship") 95 return ··· 97 98 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 99 Collection: tangled.GraphFollowNSID, 100 + Repo: currentUser.Active.Did, 101 Rkey: follow.Rkey, 102 }) 103 ··· 106 return 107 } 108 109 + err = db.DeleteFollowByRkey(s.db, currentUser.Active.Did, follow.Rkey) 110 if err != nil { 111 log.Println("failed to delete follow from DB") 112 // this is not an issue, the firehose event might have already done this 113 } 114 115 + followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) 116 + if err != nil { 117 + log.Println("failed to get follow stats", err) 118 + } 119 + 120 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 121 + UserDid: subjectIdent.DID.String(), 122 + FollowStatus: models.IsNotFollowing, 123 + FollowersCount: followStats.Followers, 124 }) 125 126 s.notifier.DeleteFollow(r.Context(), follow)
+1 -1
appview/state/gfi.go
··· 15 ) 16 17 func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 18 - user := s.oauth.GetUser(r) 19 20 page := pagination.FromContext(r.Context()) 21
··· 15 ) 16 17 func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 18 + user := s.oauth.GetMultiAccountUser(r) 19 20 page := pagination.FromContext(r.Context()) 21
+57 -7
appview/state/login.go
··· 5 "net/http" 6 "strings" 7 8 "tangled.org/core/appview/pages" 9 ) 10 ··· 15 case http.MethodGet: 16 returnURL := r.URL.Query().Get("return_url") 17 errorCode := r.URL.Query().Get("error") 18 s.pages.Login(w, pages.LoginParams{ 19 - ReturnUrl: returnURL, 20 - ErrorCode: errorCode, 21 }) 22 case http.MethodPost: 23 handle := r.FormValue("handle") 24 25 // when users copy their handle from bsky.app, it tends to have these characters around it: 26 // ··· 44 return 45 } 46 47 redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 48 if err != nil { 49 l.Error("failed to start auth", "err", err) ··· 58 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 59 l := s.logger.With("handler", "Logout") 60 61 - err := s.oauth.DeleteSession(w, r) 62 - if err != nil { 63 - l.Error("failed to logout", "err", err) 64 - } else { 65 - l.Info("logged out successfully") 66 } 67 68 s.pages.HxRedirect(w, "/login") 69 }
··· 5 "net/http" 6 "strings" 7 8 + "tangled.org/core/appview/oauth" 9 "tangled.org/core/appview/pages" 10 ) 11 ··· 16 case http.MethodGet: 17 returnURL := r.URL.Query().Get("return_url") 18 errorCode := r.URL.Query().Get("error") 19 + addAccount := r.URL.Query().Get("mode") == "add_account" 20 + 21 + user := s.oauth.GetMultiAccountUser(r) 22 + if user == nil { 23 + registry := s.oauth.GetAccounts(r) 24 + if len(registry.Accounts) > 0 { 25 + user = &oauth.MultiAccountUser{ 26 + Active: nil, 27 + Accounts: registry.Accounts, 28 + } 29 + } 30 + } 31 s.pages.Login(w, pages.LoginParams{ 32 + ReturnUrl: returnURL, 33 + ErrorCode: errorCode, 34 + AddAccount: addAccount, 35 + LoggedInUser: user, 36 }) 37 case http.MethodPost: 38 handle := r.FormValue("handle") 39 + returnURL := r.FormValue("return_url") 40 + addAccount := r.FormValue("add_account") == "true" 41 42 // when users copy their handle from bsky.app, it tends to have these characters around it: 43 // ··· 61 return 62 } 63 64 + if err := s.oauth.SetAuthReturn(w, r, returnURL, addAccount); err != nil { 65 + l.Error("failed to set auth return", "err", err) 66 + } 67 + 68 redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 69 if err != nil { 70 l.Error("failed to start auth", "err", err) ··· 79 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 80 l := s.logger.With("handler", "Logout") 81 82 + currentUser := s.oauth.GetMultiAccountUser(r) 83 + if currentUser == nil || currentUser.Active == nil { 84 + s.pages.HxRedirect(w, "/login") 85 + return 86 } 87 88 + currentDid := currentUser.Active.Did 89 + 90 + var remainingAccounts []string 91 + for _, acc := range currentUser.Accounts { 92 + if acc.Did != currentDid { 93 + remainingAccounts = append(remainingAccounts, acc.Did) 94 + } 95 + } 96 + 97 + if err := s.oauth.RemoveAccount(w, r, currentDid); err != nil { 98 + l.Error("failed to remove account from registry", "err", err) 99 + } 100 + 101 + if err := s.oauth.DeleteSession(w, r); err != nil { 102 + l.Error("failed to delete session", "err", err) 103 + } 104 + 105 + if len(remainingAccounts) > 0 { 106 + nextDid := remainingAccounts[0] 107 + if err := s.oauth.SwitchAccount(w, r, nextDid); err != nil { 108 + l.Error("failed to switch to next account", "err", err) 109 + s.pages.HxRedirect(w, "/login") 110 + return 111 + } 112 + l.Info("switched to next account after logout", "did", nextDid) 113 + s.pages.HxRefresh(w) 114 + return 115 + } 116 + 117 + l.Info("logged out last account") 118 s.pages.HxRedirect(w, "/login") 119 }
+29
appview/state/manifest.go
···
··· 1 + package state 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + ) 7 + 8 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 9 + // https://www.w3.org/TR/appmanifest/ 10 + var manifestData = map[string]any{ 11 + "name": "tangled", 12 + "description": "tightly-knit social coding.", 13 + "icons": []map[string]string{ 14 + { 15 + "src": "/static/logos/dolly.svg", 16 + "sizes": "144x144", 17 + }, 18 + }, 19 + "start_url": "/", 20 + "id": "https://tangled.org", 21 + "display": "standalone", 22 + "background_color": "#111827", 23 + "theme_color": "#111827", 24 + } 25 + 26 + func (p *State) WebAppManifest(w http.ResponseWriter, r *http.Request) { 27 + w.Header().Set("Content-Type", "application/manifest+json") 28 + json.NewEncoder(w).Encode(manifestData) 29 + }
+38 -36
appview/state/profile.go
··· 77 return nil, fmt.Errorf("failed to get follower stats: %w", err) 78 } 79 80 - loggedInUser := s.oauth.GetUser(r) 81 followStatus := models.IsNotFollowing 82 if loggedInUser != nil { 83 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 84 } 85 86 now := time.Now() ··· 163 } 164 165 // populate commit counts in the timeline, using the punchcard 166 - currentMonth := time.Now().Month() 167 for _, p := range profile.Punchcard.Punches { 168 - idx := currentMonth - p.Date.Month() 169 - if int(idx) < len(timeline.ByMonth) { 170 - timeline.ByMonth[idx].Commits += p.Count 171 } 172 } 173 174 s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 175 - LoggedInUser: s.oauth.GetUser(r), 176 Card: profile, 177 Repos: pinnedRepos, 178 CollaboratingRepos: pinnedCollaboratingRepos, ··· 203 } 204 205 err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 206 - LoggedInUser: s.oauth.GetUser(r), 207 Repos: repos, 208 Card: profile, 209 }) ··· 232 } 233 234 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 235 - LoggedInUser: s.oauth.GetUser(r), 236 Repos: repos, 237 Card: profile, 238 }) ··· 257 } 258 259 err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{ 260 - LoggedInUser: s.oauth.GetUser(r), 261 Strings: strings, 262 Card: profile, 263 }) ··· 281 } 282 l = l.With("profileDid", profile.UserDid) 283 284 - loggedInUser := s.oauth.GetUser(r) 285 params := FollowsPageParams{ 286 Card: profile, 287 } ··· 314 315 loggedInUserFollowing := make(map[string]struct{}) 316 if loggedInUser != nil { 317 - following, err := db.GetFollowing(s.db, loggedInUser.Did) 318 if err != nil { 319 - l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 320 return &params, err 321 } 322 loggedInUserFollowing = make(map[string]struct{}, len(following)) ··· 331 followStatus := models.IsNotFollowing 332 if _, exists := loggedInUserFollowing[did]; exists { 333 followStatus = models.IsFollowing 334 - } else if loggedInUser != nil && loggedInUser.Did == did { 335 followStatus = models.IsSelf 336 } 337 ··· 365 } 366 367 s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 368 - LoggedInUser: s.oauth.GetUser(r), 369 Followers: followPage.Follows, 370 Card: followPage.Card, 371 }) ··· 379 } 380 381 s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 382 - LoggedInUser: s.oauth.GetUser(r), 383 Following: followPage.Follows, 384 Card: followPage.Card, 385 }) ··· 528 } 529 530 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 531 - user := s.oauth.GetUser(r) 532 533 err := r.ParseForm() 534 if err != nil { ··· 537 return 538 } 539 540 - profile, err := db.GetProfile(s.db, user.Did) 541 if err != nil { 542 - log.Printf("getting profile data for %s: %s", user.Did, err) 543 } 544 545 profile.Description = r.FormValue("description") ··· 576 } 577 578 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 579 - user := s.oauth.GetUser(r) 580 581 err := r.ParseForm() 582 if err != nil { ··· 585 return 586 } 587 588 - profile, err := db.GetProfile(s.db, user.Did) 589 if err != nil { 590 - log.Printf("getting profile data for %s: %s", user.Did, err) 591 } 592 593 i := 0 ··· 615 } 616 617 func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 618 - user := s.oauth.GetUser(r) 619 tx, err := s.db.BeginTx(r.Context(), nil) 620 if err != nil { 621 log.Println("failed to start transaction", err) ··· 642 vanityStats = append(vanityStats, string(v.Kind)) 643 } 644 645 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 646 var cid *string 647 if ex != nil { 648 cid = ex.Cid ··· 650 651 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 652 Collection: tangled.ActorProfileNSID, 653 - Repo: user.Did, 654 Rkey: "self", 655 Record: &lexutil.LexiconTypeDecoder{ 656 Val: &tangled.ActorProfile{ ··· 679 680 s.notifier.UpdateProfile(r.Context(), profile) 681 682 - s.pages.HxRedirect(w, "/"+user.Did) 683 } 684 685 func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 686 - user := s.oauth.GetUser(r) 687 688 - profile, err := db.GetProfile(s.db, user.Did) 689 if err != nil { 690 - log.Printf("getting profile data for %s: %s", user.Did, err) 691 } 692 693 s.pages.EditBioFragment(w, pages.EditBioParams{ ··· 697 } 698 699 func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 700 - user := s.oauth.GetUser(r) 701 702 - profile, err := db.GetProfile(s.db, user.Did) 703 if err != nil { 704 - log.Printf("getting profile data for %s: %s", user.Did, err) 705 } 706 707 - repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Did)) 708 if err != nil { 709 - log.Printf("getting repos for %s: %s", user.Did, err) 710 } 711 712 - collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 713 if err != nil { 714 - log.Printf("getting collaborating repos for %s: %s", user.Did, err) 715 } 716 717 allRepos := []pages.PinnedRepo{}
··· 77 return nil, fmt.Errorf("failed to get follower stats: %w", err) 78 } 79 80 + loggedInUser := s.oauth.GetMultiAccountUser(r) 81 followStatus := models.IsNotFollowing 82 if loggedInUser != nil { 83 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Active.Did, did) 84 } 85 86 now := time.Now() ··· 163 } 164 165 // populate commit counts in the timeline, using the punchcard 166 + now := time.Now() 167 for _, p := range profile.Punchcard.Punches { 168 + years := now.Year() - p.Date.Year() 169 + months := int(now.Month() - p.Date.Month()) 170 + monthsAgo := years*12 + months 171 + if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) { 172 + timeline.ByMonth[monthsAgo].Commits += p.Count 173 } 174 } 175 176 s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 177 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 178 Card: profile, 179 Repos: pinnedRepos, 180 CollaboratingRepos: pinnedCollaboratingRepos, ··· 205 } 206 207 err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 208 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 209 Repos: repos, 210 Card: profile, 211 }) ··· 234 } 235 236 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 237 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 238 Repos: repos, 239 Card: profile, 240 }) ··· 259 } 260 261 err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{ 262 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 263 Strings: strings, 264 Card: profile, 265 }) ··· 283 } 284 l = l.With("profileDid", profile.UserDid) 285 286 + loggedInUser := s.oauth.GetMultiAccountUser(r) 287 params := FollowsPageParams{ 288 Card: profile, 289 } ··· 316 317 loggedInUserFollowing := make(map[string]struct{}) 318 if loggedInUser != nil { 319 + following, err := db.GetFollowing(s.db, loggedInUser.Active.Did) 320 if err != nil { 321 + l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Active.Did) 322 return &params, err 323 } 324 loggedInUserFollowing = make(map[string]struct{}, len(following)) ··· 333 followStatus := models.IsNotFollowing 334 if _, exists := loggedInUserFollowing[did]; exists { 335 followStatus = models.IsFollowing 336 + } else if loggedInUser != nil && loggedInUser.Active.Did == did { 337 followStatus = models.IsSelf 338 } 339 ··· 367 } 368 369 s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 370 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 371 Followers: followPage.Follows, 372 Card: followPage.Card, 373 }) ··· 381 } 382 383 s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 384 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 385 Following: followPage.Follows, 386 Card: followPage.Card, 387 }) ··· 530 } 531 532 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 533 + user := s.oauth.GetMultiAccountUser(r) 534 535 err := r.ParseForm() 536 if err != nil { ··· 539 return 540 } 541 542 + profile, err := db.GetProfile(s.db, user.Active.Did) 543 if err != nil { 544 + log.Printf("getting profile data for %s: %s", user.Active.Did, err) 545 } 546 547 profile.Description = r.FormValue("description") ··· 578 } 579 580 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 581 + user := s.oauth.GetMultiAccountUser(r) 582 583 err := r.ParseForm() 584 if err != nil { ··· 587 return 588 } 589 590 + profile, err := db.GetProfile(s.db, user.Active.Did) 591 if err != nil { 592 + log.Printf("getting profile data for %s: %s", user.Active.Did, err) 593 } 594 595 i := 0 ··· 617 } 618 619 func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 620 + user := s.oauth.GetMultiAccountUser(r) 621 tx, err := s.db.BeginTx(r.Context(), nil) 622 if err != nil { 623 log.Println("failed to start transaction", err) ··· 644 vanityStats = append(vanityStats, string(v.Kind)) 645 } 646 647 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Active.Did, "self") 648 var cid *string 649 if ex != nil { 650 cid = ex.Cid ··· 652 653 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 654 Collection: tangled.ActorProfileNSID, 655 + Repo: user.Active.Did, 656 Rkey: "self", 657 Record: &lexutil.LexiconTypeDecoder{ 658 Val: &tangled.ActorProfile{ ··· 681 682 s.notifier.UpdateProfile(r.Context(), profile) 683 684 + s.pages.HxRedirect(w, "/"+user.Active.Did) 685 } 686 687 func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 688 + user := s.oauth.GetMultiAccountUser(r) 689 690 + profile, err := db.GetProfile(s.db, user.Active.Did) 691 if err != nil { 692 + log.Printf("getting profile data for %s: %s", user.Active.Did, err) 693 } 694 695 s.pages.EditBioFragment(w, pages.EditBioParams{ ··· 699 } 700 701 func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 702 + user := s.oauth.GetMultiAccountUser(r) 703 704 + profile, err := db.GetProfile(s.db, user.Active.Did) 705 if err != nil { 706 + log.Printf("getting profile data for %s: %s", user.Active.Did, err) 707 } 708 709 + repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Active.Did)) 710 if err != nil { 711 + log.Printf("getting repos for %s: %s", user.Active.Did, err) 712 } 713 714 + collaboratingRepos, err := db.CollaboratingIn(s.db, user.Active.Did) 715 if err != nil { 716 + log.Printf("getting collaborating repos for %s: %s", user.Active.Did, err) 717 } 718 719 allRepos := []pages.PinnedRepo{}
+7 -7
appview/state/reaction.go
··· 17 ) 18 19 func (s *State) React(w http.ResponseWriter, r *http.Request) { 20 - currentUser := s.oauth.GetUser(r) 21 22 subject := r.URL.Query().Get("subject") 23 if subject == "" { ··· 49 rkey := tid.TID() 50 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 Collection: tangled.FeedReactionNSID, 52 - Repo: currentUser.Did, 53 Rkey: rkey, 54 Record: &lexutil.LexiconTypeDecoder{ 55 Val: &tangled.FeedReaction{ ··· 64 return 65 } 66 67 - err = db.AddReaction(s.db, currentUser.Did, subjectUri, reactionKind, rkey) 68 if err != nil { 69 log.Println("failed to react", err) 70 return ··· 87 88 return 89 case http.MethodDelete: 90 - reaction, err := db.GetReaction(s.db, currentUser.Did, subjectUri, reactionKind) 91 if err != nil { 92 - log.Println("failed to get reaction relationship for", currentUser.Did, subjectUri) 93 return 94 } 95 96 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 97 Collection: tangled.FeedReactionNSID, 98 - Repo: currentUser.Did, 99 Rkey: reaction.Rkey, 100 }) 101 ··· 104 return 105 } 106 107 - err = db.DeleteReactionByRkey(s.db, currentUser.Did, reaction.Rkey) 108 if err != nil { 109 log.Println("failed to delete reaction from DB") 110 // this is not an issue, the firehose event might have already done this
··· 17 ) 18 19 func (s *State) React(w http.ResponseWriter, r *http.Request) { 20 + currentUser := s.oauth.GetMultiAccountUser(r) 21 22 subject := r.URL.Query().Get("subject") 23 if subject == "" { ··· 49 rkey := tid.TID() 50 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 Collection: tangled.FeedReactionNSID, 52 + Repo: currentUser.Active.Did, 53 Rkey: rkey, 54 Record: &lexutil.LexiconTypeDecoder{ 55 Val: &tangled.FeedReaction{ ··· 64 return 65 } 66 67 + err = db.AddReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind, rkey) 68 if err != nil { 69 log.Println("failed to react", err) 70 return ··· 87 88 return 89 case http.MethodDelete: 90 + reaction, err := db.GetReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind) 91 if err != nil { 92 + log.Println("failed to get reaction relationship for", currentUser.Active.Did, subjectUri) 93 return 94 } 95 96 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 97 Collection: tangled.FeedReactionNSID, 98 + Repo: currentUser.Active.Did, 99 Rkey: reaction.Rkey, 100 }) 101 ··· 104 return 105 } 106 107 + err = db.DeleteReactionByRkey(s.db, currentUser.Active.Did, reaction.Rkey) 108 if err != nil { 109 log.Println("failed to delete reaction from DB") 110 // this is not an issue, the firehose event might have already done this
+9 -6
appview/state/router.go
··· 32 s.pages, 33 ) 34 35 - router.Get("/favicon.svg", s.Favicon) 36 - router.Get("/favicon.ico", s.Favicon) 37 - router.Get("/pwa-manifest.json", s.PWAManifest) 38 router.Get("/robots.txt", s.RobotsTxt) 39 40 userRouter := s.UserRouter(&middleware) ··· 96 r.Mount("/", s.RepoRouter(mw)) 97 r.Mount("/issues", s.IssuesRouter(mw)) 98 r.Mount("/pulls", s.PullsRouter(mw)) 99 - r.Mount("/pipelines", s.PipelinesRouter()) 100 r.Mount("/labels", s.LabelsRouter()) 101 102 // These routes get proxied to the knot ··· 109 }) 110 111 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 112 s.pages.Error404(w) 113 }) 114 ··· 131 r.Post("/login", s.Login) 132 r.Post("/logout", s.Logout) 133 134 r.Route("/repo", func(r chi.Router) { 135 r.Route("/new", func(r chi.Router) { 136 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 182 r.Get("/brand", s.Brand) 183 184 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 185 s.pages.Error404(w) 186 }) 187 return r ··· 313 return repo.Router(mw) 314 } 315 316 - func (s *State) PipelinesRouter() http.Handler { 317 pipes := pipelines.New( 318 s.oauth, 319 s.repoResolver, ··· 325 s.enforcer, 326 log.SubLogger(s.logger, "pipelines"), 327 ) 328 - return pipes.Router() 329 } 330 331 func (s *State) LabelsRouter() http.Handler {
··· 32 s.pages, 33 ) 34 35 + router.Get("/pwa-manifest.json", s.WebAppManifest) 36 router.Get("/robots.txt", s.RobotsTxt) 37 38 userRouter := s.UserRouter(&middleware) ··· 94 r.Mount("/", s.RepoRouter(mw)) 95 r.Mount("/issues", s.IssuesRouter(mw)) 96 r.Mount("/pulls", s.PullsRouter(mw)) 97 + r.Mount("/pipelines", s.PipelinesRouter(mw)) 98 r.Mount("/labels", s.LabelsRouter()) 99 100 // These routes get proxied to the knot ··· 107 }) 108 109 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 110 + w.WriteHeader(http.StatusNotFound) 111 s.pages.Error404(w) 112 }) 113 ··· 130 r.Post("/login", s.Login) 131 r.Post("/logout", s.Logout) 132 133 + r.Post("/account/switch", s.SwitchAccount) 134 + r.With(middleware.AuthMiddleware(s.oauth)).Delete("/account/{did}", s.RemoveAccount) 135 + 136 r.Route("/repo", func(r chi.Router) { 137 r.Route("/new", func(r chi.Router) { 138 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 184 r.Get("/brand", s.Brand) 185 186 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 187 + w.WriteHeader(http.StatusNotFound) 188 s.pages.Error404(w) 189 }) 190 return r ··· 316 return repo.Router(mw) 317 } 318 319 + func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 320 pipes := pipelines.New( 321 s.oauth, 322 s.repoResolver, ··· 328 s.enforcer, 329 log.SubLogger(s.logger, "pipelines"), 330 ) 331 + return pipes.Router(mw) 332 } 333 334 func (s *State) LabelsRouter() http.Handler {
+6 -6
appview/state/star.go
··· 16 ) 17 18 func (s *State) Star(w http.ResponseWriter, r *http.Request) { 19 - currentUser := s.oauth.GetUser(r) 20 21 subject := r.URL.Query().Get("subject") 22 if subject == "" { ··· 42 rkey := tid.TID() 43 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 - Repo: currentUser.Did, 46 Rkey: rkey, 47 Record: &lexutil.LexiconTypeDecoder{ 48 Val: &tangled.FeedStar{ ··· 57 log.Println("created atproto record: ", resp.Uri) 58 59 star := &models.Star{ 60 - Did: currentUser.Did, 61 RepoAt: subjectUri, 62 Rkey: rkey, 63 } ··· 84 return 85 case http.MethodDelete: 86 // find the record in the db 87 - star, err := db.GetStar(s.db, currentUser.Did, subjectUri) 88 if err != nil { 89 log.Println("failed to get star relationship") 90 return ··· 92 93 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 94 Collection: tangled.FeedStarNSID, 95 - Repo: currentUser.Did, 96 Rkey: star.Rkey, 97 }) 98 ··· 101 return 102 } 103 104 - err = db.DeleteStarByRkey(s.db, currentUser.Did, star.Rkey) 105 if err != nil { 106 log.Println("failed to delete star from DB") 107 // this is not an issue, the firehose event might have already done this
··· 16 ) 17 18 func (s *State) Star(w http.ResponseWriter, r *http.Request) { 19 + currentUser := s.oauth.GetMultiAccountUser(r) 20 21 subject := r.URL.Query().Get("subject") 22 if subject == "" { ··· 42 rkey := tid.TID() 43 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 + Repo: currentUser.Active.Did, 46 Rkey: rkey, 47 Record: &lexutil.LexiconTypeDecoder{ 48 Val: &tangled.FeedStar{ ··· 57 log.Println("created atproto record: ", resp.Uri) 58 59 star := &models.Star{ 60 + Did: currentUser.Active.Did, 61 RepoAt: subjectUri, 62 Rkey: rkey, 63 } ··· 84 return 85 case http.MethodDelete: 86 // find the record in the db 87 + star, err := db.GetStar(s.db, currentUser.Active.Did, subjectUri) 88 if err != nil { 89 log.Println("failed to get star relationship") 90 return ··· 92 93 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 94 Collection: tangled.FeedStarNSID, 95 + Repo: currentUser.Active.Did, 96 Rkey: star.Rkey, 97 }) 98 ··· 101 return 102 } 103 104 + err = db.DeleteStarByRkey(s.db, currentUser.Active.Did, star.Rkey) 105 if err != nil { 106 log.Println("failed to delete star from DB") 107 // this is not an issue, the firehose event might have already done this
+22 -58
appview/state/state.go
··· 202 return s.db.Close() 203 } 204 205 - func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 206 - w.Header().Set("Content-Type", "image/svg+xml") 207 - w.Header().Set("Cache-Control", "public, max-age=31536000") // one year 208 - w.Header().Set("ETag", `"favicon-svg-v1"`) 209 - 210 - if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { 211 - w.WriteHeader(http.StatusNotModified) 212 - return 213 - } 214 - 215 - s.pages.Favicon(w) 216 - } 217 - 218 func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 219 w.Header().Set("Content-Type", "text/plain") 220 w.Header().Set("Cache-Control", "public, max-age=86400") // one day ··· 225 w.Write([]byte(robotsTxt)) 226 } 227 228 - // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 229 - const manifestJson = `{ 230 - "name": "tangled", 231 - "description": "tightly-knit social coding.", 232 - "icons": [ 233 - { 234 - "src": "/favicon.svg", 235 - "sizes": "144x144" 236 - } 237 - ], 238 - "start_url": "/", 239 - "id": "org.tangled", 240 - 241 - "display": "standalone", 242 - "background_color": "#111827", 243 - "theme_color": "#111827" 244 - }` 245 - 246 - func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 247 - w.Header().Set("Content-Type", "application/json") 248 - w.Write([]byte(manifestJson)) 249 - } 250 - 251 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 252 - user := s.oauth.GetUser(r) 253 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 254 LoggedInUser: user, 255 }) 256 } 257 258 func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 259 - user := s.oauth.GetUser(r) 260 s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 261 LoggedInUser: user, 262 }) 263 } 264 265 func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 266 - user := s.oauth.GetUser(r) 267 s.pages.Brand(w, pages.BrandParams{ 268 LoggedInUser: user, 269 }) 270 } 271 272 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 273 - if s.oauth.GetUser(r) != nil { 274 s.Timeline(w, r) 275 return 276 } ··· 278 } 279 280 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 281 - user := s.oauth.GetUser(r) 282 283 // TODO: set this flag based on the UI 284 filtered := false 285 286 var userDid string 287 - if user != nil { 288 - userDid = user.Did 289 } 290 timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 291 if err != nil { ··· 314 } 315 316 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 317 - user := s.oauth.GetUser(r) 318 if user == nil { 319 return 320 } 321 322 l := s.logger.With("handler", "UpgradeBanner") 323 - l = l.With("did", user.Did) 324 325 regs, err := db.GetRegistrations( 326 s.db, 327 - orm.FilterEq("did", user.Did), 328 orm.FilterEq("needs_upgrade", 1), 329 ) 330 if err != nil { ··· 333 334 spindles, err := db.GetSpindles( 335 s.db, 336 - orm.FilterEq("owner", user.Did), 337 orm.FilterEq("needs_upgrade", 1), 338 ) 339 if err != nil { ··· 447 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 448 switch r.Method { 449 case http.MethodGet: 450 - user := s.oauth.GetUser(r) 451 - knots, err := s.enforcer.GetKnotsForUser(user.Did) 452 if err != nil { 453 s.pages.Notice(w, "repo", "Invalid user account.") 454 return ··· 462 case http.MethodPost: 463 l := s.logger.With("handler", "NewRepo") 464 465 - user := s.oauth.GetUser(r) 466 - l = l.With("did", user.Did) 467 468 // form validation 469 domain := r.FormValue("domain") ··· 495 description := r.FormValue("description") 496 497 // ACL validation 498 - ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 499 if err != nil || !ok { 500 l.Info("unauthorized") 501 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") ··· 505 // Check for existing repos 506 existingRepo, err := db.GetRepo( 507 s.db, 508 - orm.FilterEq("did", user.Did), 509 orm.FilterEq("name", repoName), 510 ) 511 if err == nil && existingRepo != nil { ··· 517 // create atproto record for this repo 518 rkey := tid.TID() 519 repo := &models.Repo{ 520 - Did: user.Did, 521 Name: repoName, 522 Knot: domain, 523 Rkey: rkey, ··· 536 537 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 538 Collection: tangled.RepoNSID, 539 - Repo: user.Did, 540 Rkey: rkey, 541 Record: &lexutil.LexiconTypeDecoder{ 542 Val: &record, ··· 613 } 614 615 // acls 616 - p, _ := securejoin.SecureJoin(user.Did, repoName) 617 - err = s.enforcer.AddRepo(user.Did, domain, p) 618 if err != nil { 619 l.Error("acl setup failed", "err", err) 620 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 639 aturi = "" 640 641 s.notifier.NewRepo(r.Context(), repo) 642 - s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName)) 643 } 644 } 645
··· 202 return s.db.Close() 203 } 204 205 func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 206 w.Header().Set("Content-Type", "text/plain") 207 w.Header().Set("Cache-Control", "public, max-age=86400") // one day ··· 212 w.Write([]byte(robotsTxt)) 213 } 214 215 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 216 + user := s.oauth.GetMultiAccountUser(r) 217 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 218 LoggedInUser: user, 219 }) 220 } 221 222 func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 223 + user := s.oauth.GetMultiAccountUser(r) 224 s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 225 LoggedInUser: user, 226 }) 227 } 228 229 func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 230 + user := s.oauth.GetMultiAccountUser(r) 231 s.pages.Brand(w, pages.BrandParams{ 232 LoggedInUser: user, 233 }) 234 } 235 236 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 237 + if s.oauth.GetMultiAccountUser(r) != nil { 238 s.Timeline(w, r) 239 return 240 } ··· 242 } 243 244 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 245 + user := s.oauth.GetMultiAccountUser(r) 246 247 // TODO: set this flag based on the UI 248 filtered := false 249 250 var userDid string 251 + if user != nil && user.Active != nil { 252 + userDid = user.Active.Did 253 } 254 timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 255 if err != nil { ··· 278 } 279 280 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 281 + user := s.oauth.GetMultiAccountUser(r) 282 if user == nil { 283 return 284 } 285 286 l := s.logger.With("handler", "UpgradeBanner") 287 + l = l.With("did", user.Active.Did) 288 289 regs, err := db.GetRegistrations( 290 s.db, 291 + orm.FilterEq("did", user.Active.Did), 292 orm.FilterEq("needs_upgrade", 1), 293 ) 294 if err != nil { ··· 297 298 spindles, err := db.GetSpindles( 299 s.db, 300 + orm.FilterEq("owner", user.Active.Did), 301 orm.FilterEq("needs_upgrade", 1), 302 ) 303 if err != nil { ··· 411 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 412 switch r.Method { 413 case http.MethodGet: 414 + user := s.oauth.GetMultiAccountUser(r) 415 + knots, err := s.enforcer.GetKnotsForUser(user.Active.Did) 416 if err != nil { 417 s.pages.Notice(w, "repo", "Invalid user account.") 418 return ··· 426 case http.MethodPost: 427 l := s.logger.With("handler", "NewRepo") 428 429 + user := s.oauth.GetMultiAccountUser(r) 430 + l = l.With("did", user.Active.Did) 431 432 // form validation 433 domain := r.FormValue("domain") ··· 459 description := r.FormValue("description") 460 461 // ACL validation 462 + ok, err := s.enforcer.E.Enforce(user.Active.Did, domain, domain, "repo:create") 463 if err != nil || !ok { 464 l.Info("unauthorized") 465 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") ··· 469 // Check for existing repos 470 existingRepo, err := db.GetRepo( 471 s.db, 472 + orm.FilterEq("did", user.Active.Did), 473 orm.FilterEq("name", repoName), 474 ) 475 if err == nil && existingRepo != nil { ··· 481 // create atproto record for this repo 482 rkey := tid.TID() 483 repo := &models.Repo{ 484 + Did: user.Active.Did, 485 Name: repoName, 486 Knot: domain, 487 Rkey: rkey, ··· 500 501 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 502 Collection: tangled.RepoNSID, 503 + Repo: user.Active.Did, 504 Rkey: rkey, 505 Record: &lexutil.LexiconTypeDecoder{ 506 Val: &record, ··· 577 } 578 579 // acls 580 + p, _ := securejoin.SecureJoin(user.Active.Did, repoName) 581 + err = s.enforcer.AddRepo(user.Active.Did, domain, p) 582 if err != nil { 583 l.Error("acl setup failed", "err", err) 584 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 603 aturi = "" 604 605 s.notifier.NewRepo(r.Context(), repo) 606 + s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Active.Did, repoName)) 607 } 608 } 609
+19 -19
appview/strings/strings.go
··· 82 } 83 84 s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 85 - LoggedInUser: s.OAuth.GetUser(r), 86 Strings: strings, 87 }) 88 } ··· 153 if err != nil { 154 l.Error("failed to get star count", "err", err) 155 } 156 - user := s.OAuth.GetUser(r) 157 isStarred := false 158 if user != nil { 159 - isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri()) 160 } 161 162 s.Pages.SingleString(w, pages.SingleStringParams{ ··· 178 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 179 l := s.Logger.With("handler", "edit") 180 181 - user := s.OAuth.GetUser(r) 182 183 id, ok := r.Context().Value("resolvedId").(identity.Identity) 184 if !ok { ··· 216 first := all[0] 217 218 // verify that the logged in user owns this string 219 - if user.Did != id.DID.String() { 220 - l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 221 w.WriteHeader(http.StatusUnauthorized) 222 return 223 } ··· 226 case http.MethodGet: 227 // return the form with prefilled fields 228 s.Pages.PutString(w, pages.PutStringParams{ 229 - LoggedInUser: s.OAuth.GetUser(r), 230 Action: "edit", 231 String: first, 232 }) ··· 299 s.Notifier.EditString(r.Context(), &entry) 300 301 // if that went okay, redir to the string 302 - s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 303 } 304 305 } 306 307 func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 308 l := s.Logger.With("handler", "create") 309 - user := s.OAuth.GetUser(r) 310 311 switch r.Method { 312 case http.MethodGet: 313 s.Pages.PutString(w, pages.PutStringParams{ 314 - LoggedInUser: s.OAuth.GetUser(r), 315 Action: "new", 316 }) 317 case http.MethodPost: ··· 335 description := r.FormValue("description") 336 337 string := models.String{ 338 - Did: syntax.DID(user.Did), 339 Rkey: tid.TID(), 340 Filename: filename, 341 Description: description, ··· 353 354 resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 355 Collection: tangled.StringNSID, 356 - Repo: user.Did, 357 Rkey: string.Rkey, 358 Record: &lexutil.LexiconTypeDecoder{ 359 Val: &record, ··· 375 s.Notifier.NewString(r.Context(), &string) 376 377 // successful 378 - s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 379 } 380 } 381 382 func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 383 l := s.Logger.With("handler", "create") 384 - user := s.OAuth.GetUser(r) 385 fail := func(msg string, err error) { 386 l.Error(msg, "err", err) 387 s.Pages.Notice(w, "error", msg) ··· 402 return 403 } 404 405 - if user.Did != id.DID.String() { 406 - fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 407 return 408 } 409 410 if err := db.DeleteString( 411 s.Db, 412 - orm.FilterEq("did", user.Did), 413 orm.FilterEq("rkey", rkey), 414 ); err != nil { 415 fail("Failed to delete string.", err) 416 return 417 } 418 419 - s.Notifier.DeleteString(r.Context(), user.Did, rkey) 420 421 - s.Pages.HxRedirect(w, "/strings/"+user.Did) 422 } 423 424 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
··· 82 } 83 84 s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 85 + LoggedInUser: s.OAuth.GetMultiAccountUser(r), 86 Strings: strings, 87 }) 88 } ··· 153 if err != nil { 154 l.Error("failed to get star count", "err", err) 155 } 156 + user := s.OAuth.GetMultiAccountUser(r) 157 isStarred := false 158 if user != nil { 159 + isStarred = db.GetStarStatus(s.Db, user.Active.Did, string.AtUri()) 160 } 161 162 s.Pages.SingleString(w, pages.SingleStringParams{ ··· 178 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 179 l := s.Logger.With("handler", "edit") 180 181 + user := s.OAuth.GetMultiAccountUser(r) 182 183 id, ok := r.Context().Value("resolvedId").(identity.Identity) 184 if !ok { ··· 216 first := all[0] 217 218 // verify that the logged in user owns this string 219 + if user.Active.Did != id.DID.String() { 220 + l.Error("unauthorized request", "expected", id.DID, "got", user.Active.Did) 221 w.WriteHeader(http.StatusUnauthorized) 222 return 223 } ··· 226 case http.MethodGet: 227 // return the form with prefilled fields 228 s.Pages.PutString(w, pages.PutStringParams{ 229 + LoggedInUser: s.OAuth.GetMultiAccountUser(r), 230 Action: "edit", 231 String: first, 232 }) ··· 299 s.Notifier.EditString(r.Context(), &entry) 300 301 // if that went okay, redir to the string 302 + s.Pages.HxRedirect(w, "/strings/"+user.Active.Did+"/"+entry.Rkey) 303 } 304 305 } 306 307 func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 308 l := s.Logger.With("handler", "create") 309 + user := s.OAuth.GetMultiAccountUser(r) 310 311 switch r.Method { 312 case http.MethodGet: 313 s.Pages.PutString(w, pages.PutStringParams{ 314 + LoggedInUser: s.OAuth.GetMultiAccountUser(r), 315 Action: "new", 316 }) 317 case http.MethodPost: ··· 335 description := r.FormValue("description") 336 337 string := models.String{ 338 + Did: syntax.DID(user.Active.Did), 339 Rkey: tid.TID(), 340 Filename: filename, 341 Description: description, ··· 353 354 resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 355 Collection: tangled.StringNSID, 356 + Repo: user.Active.Did, 357 Rkey: string.Rkey, 358 Record: &lexutil.LexiconTypeDecoder{ 359 Val: &record, ··· 375 s.Notifier.NewString(r.Context(), &string) 376 377 // successful 378 + s.Pages.HxRedirect(w, "/strings/"+user.Active.Did+"/"+string.Rkey) 379 } 380 } 381 382 func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 383 l := s.Logger.With("handler", "create") 384 + user := s.OAuth.GetMultiAccountUser(r) 385 fail := func(msg string, err error) { 386 l.Error(msg, "err", err) 387 s.Pages.Notice(w, "error", msg) ··· 402 return 403 } 404 405 + if user.Active.Did != id.DID.String() { 406 + fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Active.Did, id.DID.String())) 407 return 408 } 409 410 if err := db.DeleteString( 411 s.Db, 412 + orm.FilterEq("did", user.Active.Did), 413 orm.FilterEq("rkey", rkey), 414 ); err != nil { 415 fail("Failed to delete string.", err) 416 return 417 } 418 419 + s.Notifier.DeleteString(r.Context(), user.Active.Did, rkey) 420 421 + s.Pages.HxRedirect(w, "/strings/"+user.Active.Did) 422 } 423 424 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+182
cmd/dolly/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "flag" 6 + "fmt" 7 + "image" 8 + "image/color" 9 + "image/png" 10 + "os" 11 + "path/filepath" 12 + "strconv" 13 + "strings" 14 + "text/template" 15 + 16 + "github.com/srwiley/oksvg" 17 + "github.com/srwiley/rasterx" 18 + "golang.org/x/image/draw" 19 + "tangled.org/core/appview/pages" 20 + "tangled.org/core/ico" 21 + ) 22 + 23 + func main() { 24 + var ( 25 + size string 26 + fillColor string 27 + output string 28 + ) 29 + 30 + flag.StringVar(&size, "size", "512x512", "Output size in format WIDTHxHEIGHT (e.g., 512x512)") 31 + flag.StringVar(&fillColor, "color", "#000000", "Fill color in hex format (e.g., #FF5733)") 32 + flag.StringVar(&output, "output", "dolly.svg", "Output file path (format detected from extension: .svg, .png, or .ico)") 33 + flag.Parse() 34 + 35 + width, height, err := parseSize(size) 36 + if err != nil { 37 + fmt.Fprintf(os.Stderr, "Error parsing size: %v\n", err) 38 + os.Exit(1) 39 + } 40 + 41 + // Detect format from file extension 42 + ext := strings.ToLower(filepath.Ext(output)) 43 + format := strings.TrimPrefix(ext, ".") 44 + 45 + if format != "svg" && format != "png" && format != "ico" { 46 + fmt.Fprintf(os.Stderr, "Invalid file extension: %s. Must be .svg, .png, or .ico\n", ext) 47 + os.Exit(1) 48 + } 49 + 50 + if fillColor != "currentColor" && !isValidHexColor(fillColor) { 51 + fmt.Fprintf(os.Stderr, "Invalid color format: %s. Use hex format like #FF5733\n", fillColor) 52 + os.Exit(1) 53 + } 54 + 55 + svgData, err := dolly(fillColor) 56 + if err != nil { 57 + fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err) 58 + os.Exit(1) 59 + } 60 + 61 + // Create output directory if it doesn't exist 62 + dir := filepath.Dir(output) 63 + if dir != "" && dir != "." { 64 + if err := os.MkdirAll(dir, 0755); err != nil { 65 + fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err) 66 + os.Exit(1) 67 + } 68 + } 69 + 70 + switch format { 71 + case "svg": 72 + err = saveSVG(svgData, output, width, height) 73 + case "png": 74 + err = savePNG(svgData, output, width, height) 75 + case "ico": 76 + err = saveICO(svgData, output, width, height) 77 + } 78 + 79 + if err != nil { 80 + fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err) 81 + os.Exit(1) 82 + } 83 + 84 + fmt.Printf("Successfully generated %s (%dx%d)\n", output, width, height) 85 + } 86 + 87 + func dolly(hexColor string) ([]byte, error) { 88 + tpl, err := template.New("dolly"). 89 + ParseFS(pages.Files, "templates/fragments/dolly/logo.html") 90 + if err != nil { 91 + return nil, err 92 + } 93 + 94 + var svgData bytes.Buffer 95 + if err := tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", pages.DollyParams{ 96 + FillColor: hexColor, 97 + }); err != nil { 98 + return nil, err 99 + } 100 + 101 + return svgData.Bytes(), nil 102 + } 103 + 104 + func svgToImage(svgData []byte, w, h int) (image.Image, error) { 105 + icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 106 + if err != nil { 107 + return nil, fmt.Errorf("error parsing SVG: %v", err) 108 + } 109 + 110 + icon.SetTarget(0, 0, float64(w), float64(h)) 111 + rgba := image.NewRGBA(image.Rect(0, 0, w, h)) 112 + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src) 113 + scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds()) 114 + raster := rasterx.NewDasher(w, h, scanner) 115 + icon.Draw(raster, 1.0) 116 + 117 + return rgba, nil 118 + } 119 + 120 + func parseSize(size string) (int, int, error) { 121 + parts := strings.Split(size, "x") 122 + if len(parts) != 2 { 123 + return 0, 0, fmt.Errorf("invalid size format, use WIDTHxHEIGHT") 124 + } 125 + 126 + width, err := strconv.Atoi(parts[0]) 127 + if err != nil { 128 + return 0, 0, fmt.Errorf("invalid width: %v", err) 129 + } 130 + 131 + height, err := strconv.Atoi(parts[1]) 132 + if err != nil { 133 + return 0, 0, fmt.Errorf("invalid height: %v", err) 134 + } 135 + 136 + if width <= 0 || height <= 0 { 137 + return 0, 0, fmt.Errorf("width and height must be positive") 138 + } 139 + 140 + return width, height, nil 141 + } 142 + 143 + func isValidHexColor(hex string) bool { 144 + if len(hex) != 7 || hex[0] != '#' { 145 + return false 146 + } 147 + _, err := strconv.ParseUint(hex[1:], 16, 32) 148 + return err == nil 149 + } 150 + 151 + func saveSVG(svgData []byte, filepath string, _, _ int) error { 152 + return os.WriteFile(filepath, svgData, 0644) 153 + } 154 + 155 + func savePNG(svgData []byte, filepath string, width, height int) error { 156 + img, err := svgToImage(svgData, width, height) 157 + if err != nil { 158 + return err 159 + } 160 + 161 + f, err := os.Create(filepath) 162 + if err != nil { 163 + return err 164 + } 165 + defer f.Close() 166 + 167 + return png.Encode(f, img) 168 + } 169 + 170 + func saveICO(svgData []byte, filepath string, width, height int) error { 171 + img, err := svgToImage(svgData, width, height) 172 + if err != nil { 173 + return err 174 + } 175 + 176 + icoData, err := ico.ImageToIco(img) 177 + if err != nil { 178 + return err 179 + } 180 + 181 + return os.WriteFile(filepath, icoData, 0644) 182 + }
+1530
docs/DOCS.md
···
··· 1 + --- 2 + title: Tangled docs 3 + author: The Tangled Contributors 4 + date: 21 Sun, Dec 2025 5 + abstract: | 6 + Tangled is a decentralized code hosting and collaboration 7 + platform. Every component of Tangled is open-source and 8 + self-hostable. [tangled.org](https://tangled.org) also 9 + provides hosting and CI services that are free to use. 10 + 11 + There are several models for decentralized code 12 + collaboration platforms, ranging from ActivityPubโ€™s 13 + (Forgejo) federated model, to Radicleโ€™s entirely P2P model. 14 + Our approach attempts to be the best of both worlds by 15 + adopting the AT Protocolโ€”a protocol for building decentralized 16 + social applications with a central identity 17 + 18 + Our approach to this is the idea of โ€œknotsโ€. Knots are 19 + lightweight, headless servers that enable users to host Git 20 + repositories with ease. Knots are designed for either single 21 + or multi-tenant use which is perfect for self-hosting on a 22 + Raspberry Pi at home, or larger โ€œcommunityโ€ servers. By 23 + default, Tangled provides managed knots where you can host 24 + your repositories for free. 25 + 26 + The appview at tangled.org acts as a consolidated "view" 27 + into the whole network, allowing users to access, clone and 28 + contribute to repositories hosted across different knots 29 + seamlessly. 30 + --- 31 + 32 + # Quick start guide 33 + 34 + ## Login or sign up 35 + 36 + You can [login](https://tangled.org) by using your AT Protocol 37 + account. If you are unclear on what that means, simply head 38 + to the [signup](https://tangled.org/signup) page and create 39 + an account. By doing so, you will be choosing Tangled as 40 + your account provider (you will be granted a handle of the 41 + form `user.tngl.sh`). 42 + 43 + In the AT Protocol network, users are free to choose their account 44 + provider (known as a "Personal Data Service", or PDS), and 45 + login to applications that support AT accounts. 46 + 47 + You can think of it as "one account for all of the atmosphere"! 48 + 49 + If you already have an AT account (you may have one if you 50 + signed up to Bluesky, for example), you can login with the 51 + same handle on Tangled (so just use `user.bsky.social` on 52 + the login page). 53 + 54 + ## Add an SSH key 55 + 56 + Once you are logged in, you can start creating repositories 57 + and pushing code. Tangled supports pushing git repositories 58 + over SSH. 59 + 60 + First, you'll need to generate an SSH key if you don't 61 + already have one: 62 + 63 + ```bash 64 + ssh-keygen -t ed25519 -C "foo@bar.com" 65 + ``` 66 + 67 + When prompted, save the key to the default location 68 + (`~/.ssh/id_ed25519`) and optionally set a passphrase. 69 + 70 + Copy your public key to your clipboard: 71 + 72 + ```bash 73 + # on X11 74 + cat ~/.ssh/id_ed25519.pub | xclip -sel c 75 + 76 + # on wayland 77 + cat ~/.ssh/id_ed25519.pub | wl-copy 78 + 79 + # on macos 80 + cat ~/.ssh/id_ed25519.pub | pbcopy 81 + ``` 82 + 83 + Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key', 84 + paste your public key, give it a descriptive name, and hit 85 + save. 86 + 87 + ## Create a repository 88 + 89 + Once your SSH key is added, create your first repository: 90 + 91 + 1. Hit the green `+` icon on the topbar, and select 92 + repository 93 + 2. Enter a repository name 94 + 3. Add a description 95 + 4. Choose a knotserver to host this repository on 96 + 5. Hit create 97 + 98 + Knots are self-hostable, lightweight Git servers that can 99 + host your repository. Unlike traditional code forges, your 100 + code can live on any server. Read the [Knots](TODO) section 101 + for more. 102 + 103 + ## Configure SSH 104 + 105 + To ensure Git uses the correct SSH key and connects smoothly 106 + to Tangled, add this configuration to your `~/.ssh/config` 107 + file: 108 + 109 + ``` 110 + Host tangled.org 111 + Hostname tangled.org 112 + User git 113 + IdentityFile ~/.ssh/id_ed25519 114 + AddressFamily inet 115 + ``` 116 + 117 + This tells SSH to use your specific key when connecting to 118 + Tangled and prevents authentication issues if you have 119 + multiple SSH keys. 120 + 121 + Note that this configuration only works for knotservers that 122 + are hosted by tangled.org. If you use a custom knot, refer 123 + to the [Knots](TODO) section. 124 + 125 + ## Push your first repository 126 + 127 + Initialize a new Git repository: 128 + 129 + ```bash 130 + mkdir my-project 131 + cd my-project 132 + 133 + git init 134 + echo "# My Project" > README.md 135 + ``` 136 + 137 + Add some content and push! 138 + 139 + ```bash 140 + git add README.md 141 + git commit -m "Initial commit" 142 + git remote add origin git@tangled.org:user.tngl.sh/my-project 143 + git push -u origin main 144 + ``` 145 + 146 + That's it! Your code is now hosted on Tangled. 147 + 148 + ## Migrating an existing repository 149 + 150 + Moving your repositories from GitHub, GitLab, Bitbucket, or 151 + any other Git forge to Tangled is straightforward. You'll 152 + simply change your repository's remote URL. At the moment, 153 + Tangled does not have any tooling to migrate data such as 154 + GitHub issues or pull requests. 155 + 156 + First, create a new repository on tangled.org as described 157 + in the [Quick Start Guide](#create-a-repository). 158 + 159 + Navigate to your existing local repository: 160 + 161 + ```bash 162 + cd /path/to/your/existing/repo 163 + ``` 164 + 165 + You can inspect your existing Git remote like so: 166 + 167 + ```bash 168 + git remote -v 169 + ``` 170 + 171 + You'll see something like: 172 + 173 + ``` 174 + origin git@github.com:username/my-project (fetch) 175 + origin git@github.com:username/my-project (push) 176 + ``` 177 + 178 + Update the remote URL to point to tangled: 179 + 180 + ```bash 181 + git remote set-url origin git@tangled.org:user.tngl.sh/my-project 182 + ``` 183 + 184 + Verify the change: 185 + 186 + ```bash 187 + git remote -v 188 + ``` 189 + 190 + You should now see: 191 + 192 + ``` 193 + origin git@tangled.org:user.tngl.sh/my-project (fetch) 194 + origin git@tangled.org:user.tngl.sh/my-project (push) 195 + ``` 196 + 197 + Push all your branches and tags to Tangled: 198 + 199 + ```bash 200 + git push -u origin --all 201 + git push -u origin --tags 202 + ``` 203 + 204 + Your repository is now migrated to Tangled! All commit 205 + history, branches, and tags have been preserved. 206 + 207 + ## Mirroring a repository to Tangled 208 + 209 + If you want to maintain your repository on multiple forges 210 + simultaneously, for example, keeping your primary repository 211 + on GitHub while mirroring to Tangled for backup or 212 + redundancy, you can do so by adding multiple remotes. 213 + 214 + You can configure your local repository to push to both 215 + Tangled and, say, GitHub. You may already have the following 216 + setup: 217 + 218 + ``` 219 + $ git remote -v 220 + origin git@github.com:username/my-project (fetch) 221 + origin git@github.com:username/my-project (push) 222 + ``` 223 + 224 + Now add Tangled as an additional push URL to the same 225 + remote: 226 + 227 + ```bash 228 + git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project 229 + ``` 230 + 231 + You also need to re-add the original URL as a push 232 + destination (Git replaces the push URL when you use `--add` 233 + the first time): 234 + 235 + ```bash 236 + git remote set-url --add --push origin git@github.com:username/my-project 237 + ``` 238 + 239 + Verify your configuration: 240 + 241 + ``` 242 + $ git remote -v 243 + origin git@github.com:username/repo (fetch) 244 + origin git@tangled.org:username/my-project (push) 245 + origin git@github.com:username/repo (push) 246 + ``` 247 + 248 + Notice that there's one fetch URL (the primary remote) and 249 + two push URLs. Now, whenever you push, Git will 250 + automatically push to both remotes: 251 + 252 + ```bash 253 + git push origin main 254 + ``` 255 + 256 + This single command pushes your `main` branch to both GitHub 257 + and Tangled simultaneously. 258 + 259 + To push all branches and tags: 260 + 261 + ```bash 262 + git push origin --all 263 + git push origin --tags 264 + ``` 265 + 266 + If you prefer more control over which remote you push to, 267 + you can maintain separate remotes: 268 + 269 + ```bash 270 + git remote add github git@github.com:username/my-project 271 + git remote add tangled git@tangled.org:username/my-project 272 + ``` 273 + 274 + Then push to each explicitly: 275 + 276 + ```bash 277 + git push github main 278 + git push tangled main 279 + ``` 280 + 281 + # Knot self-hosting guide 282 + 283 + So you want to run your own knot server? Great! Here are a few prerequisites: 284 + 285 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 286 + 2. A (sub)domain name. People generally use `knot.example.com`. 287 + 3. A valid SSL certificate for your domain. 288 + 289 + ## NixOS 290 + 291 + Refer to the [knot 292 + module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix) 293 + for a full list of options. Sample configurations: 294 + 295 + - [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85) 296 + - [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/d19571cc1b5fe01035e1e6951ec8cf8a476b4dee/hosts/marvin/services/tangled.nix#L15-25) 297 + 298 + ## Docker 299 + 300 + Refer to 301 + [@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker). 302 + Note that this is community maintained. 303 + 304 + ## Manual setup 305 + 306 + First, clone this repository: 307 + 308 + ``` 309 + git clone https://tangled.org/@tangled.org/core 310 + ``` 311 + 312 + Then, build the `knot` CLI. This is the knot administration 313 + and operation tool. For the purpose of this guide, we're 314 + only concerned with these subcommands: 315 + 316 + * `knot server`: the main knot server process, typically 317 + run as a supervised service 318 + * `knot guard`: handles role-based access control for git 319 + over SSH (you'll never have to run this yourself) 320 + * `knot keys`: fetches SSH keys associated with your knot; 321 + we'll use this to generate the SSH 322 + `AuthorizedKeysCommand` 323 + 324 + ``` 325 + cd core 326 + export CGO_ENABLED=1 327 + go build -o knot ./cmd/knot 328 + ``` 329 + 330 + Next, move the `knot` binary to a location owned by `root` -- 331 + `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 332 + 333 + ``` 334 + sudo mv knot /usr/local/bin/knot 335 + sudo chown root:root /usr/local/bin/knot 336 + ``` 337 + 338 + This is necessary because SSH `AuthorizedKeysCommand` requires [really 339 + specific permissions](https://stackoverflow.com/a/27638306). The 340 + `AuthorizedKeysCommand` specifies a command that is run by `sshd` to 341 + retrieve a user's public SSH keys dynamically for authentication. Let's 342 + set that up. 343 + 344 + ``` 345 + sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 346 + Match User git 347 + AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 348 + AuthorizedKeysCommandUser nobody 349 + EOF 350 + ``` 351 + 352 + Then, reload `sshd`: 353 + 354 + ``` 355 + sudo systemctl reload ssh 356 + ``` 357 + 358 + Next, create the `git` user. We'll use the `git` user's home directory 359 + to store repositories: 360 + 361 + ``` 362 + sudo adduser git 363 + ``` 364 + 365 + Create `/home/git/.knot.env` with the following, updating the values as 366 + necessary. The `KNOT_SERVER_OWNER` should be set to your 367 + DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 368 + 369 + ``` 370 + KNOT_REPO_SCAN_PATH=/home/git 371 + KNOT_SERVER_HOSTNAME=knot.example.com 372 + APPVIEW_ENDPOINT=https://tangled.org 373 + KNOT_SERVER_OWNER=did:plc:foobar 374 + KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 375 + KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 376 + ``` 377 + 378 + If you run a Linux distribution that uses systemd, you can use the provided 379 + service file to run the server. Copy 380 + [`knotserver.service`](/systemd/knotserver.service) 381 + to `/etc/systemd/system/`. Then, run: 382 + 383 + ``` 384 + systemctl enable knotserver 385 + systemctl start knotserver 386 + ``` 387 + 388 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 389 + knot. Here's an example configuration for Nginx: 390 + 391 + ``` 392 + server { 393 + listen 80; 394 + listen [::]:80; 395 + server_name knot.example.com; 396 + 397 + location / { 398 + proxy_pass http://localhost:5555; 399 + proxy_set_header Host $host; 400 + proxy_set_header X-Real-IP $remote_addr; 401 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 402 + proxy_set_header X-Forwarded-Proto $scheme; 403 + } 404 + 405 + # wss endpoint for git events 406 + location /events { 407 + proxy_set_header X-Forwarded-For $remote_addr; 408 + proxy_set_header Host $http_host; 409 + proxy_set_header Upgrade websocket; 410 + proxy_set_header Connection Upgrade; 411 + proxy_pass http://localhost:5555; 412 + } 413 + # additional config for SSL/TLS go here. 414 + } 415 + 416 + ``` 417 + 418 + Remember to use Let's Encrypt or similar to procure a certificate for your 419 + knot domain. 420 + 421 + You should now have a running knot server! You can finalize 422 + your registration by hitting the `verify` button on the 423 + [/settings/knots](https://tangled.org/settings/knots) page. This simply creates 424 + a record on your PDS to announce the existence of the knot. 425 + 426 + ### Custom paths 427 + 428 + (This section applies to manual setup only. Docker users should edit the mounts 429 + in `docker-compose.yml` instead.) 430 + 431 + Right now, the database and repositories of your knot lives in `/home/git`. You 432 + can move these paths if you'd like to store them in another folder. Be careful 433 + when adjusting these paths: 434 + 435 + * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 436 + any possible side effects. Remember to restart it once you're done. 437 + * Make backups before moving in case something goes wrong. 438 + * Make sure the `git` user can read and write from the new paths. 439 + 440 + #### Database 441 + 442 + As an example, let's say the current database is at `/home/git/knotserver.db`, 443 + and we want to move it to `/home/git/database/knotserver.db`. 444 + 445 + Copy the current database to the new location. Make sure to copy the `.db-shm` 446 + and `.db-wal` files if they exist. 447 + 448 + ``` 449 + mkdir /home/git/database 450 + cp /home/git/knotserver.db* /home/git/database 451 + ``` 452 + 453 + In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 454 + the new file path (_not_ the directory): 455 + 456 + ``` 457 + KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 458 + ``` 459 + 460 + #### Repositories 461 + 462 + As an example, let's say the repositories are currently in `/home/git`, and we 463 + want to move them into `/home/git/repositories`. 464 + 465 + Create the new folder, then move the existing repositories (if there are any): 466 + 467 + ``` 468 + mkdir /home/git/repositories 469 + # move all DIDs into the new folder; these will vary for you! 470 + mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 471 + ``` 472 + 473 + In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 474 + to the new directory: 475 + 476 + ``` 477 + KNOT_REPO_SCAN_PATH=/home/git/repositories 478 + ``` 479 + 480 + Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated 481 + repository path: 482 + 483 + ``` 484 + sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 485 + Match User git 486 + AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories 487 + AuthorizedKeysCommandUser nobody 488 + EOF 489 + ``` 490 + 491 + Make sure to restart your SSH server! 492 + 493 + #### MOTD (message of the day) 494 + 495 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 496 + `/home/git/motd` file: 497 + 498 + ``` 499 + printf "Hi from this knot!\n" > /home/git/motd 500 + ``` 501 + 502 + Note that you should add a newline at the end if setting a non-empty message 503 + since the knot won't do this for you. 504 + 505 + # Spindles 506 + 507 + ## Pipelines 508 + 509 + Spindle workflows allow you to write CI/CD pipelines in a 510 + simple format. They're located in the `.tangled/workflows` 511 + directory at the root of your repository, and are defined 512 + using YAML. 513 + 514 + The fields are: 515 + 516 + - [Trigger](#trigger): A **required** field that defines 517 + when a workflow should be triggered. 518 + - [Engine](#engine): A **required** field that defines which 519 + engine a workflow should run on. 520 + - [Clone options](#clone-options): An **optional** field 521 + that defines how the repository should be cloned. 522 + - [Dependencies](#dependencies): An **optional** field that 523 + allows you to list dependencies you may need. 524 + - [Environment](#environment): An **optional** field that 525 + allows you to define environment variables. 526 + - [Steps](#steps): An **optional** field that allows you to 527 + define what steps should run in the workflow. 528 + 529 + ### Trigger 530 + 531 + The first thing to add to a workflow is the trigger, which 532 + defines when a workflow runs. This is defined using a `when` 533 + field, which takes in a list of conditions. Each condition 534 + has the following fields: 535 + 536 + - `event`: This is a **required** field that defines when 537 + your workflow should run. It's a list that can take one or 538 + more of the following values: 539 + - `push`: The workflow should run every time a commit is 540 + pushed to the repository. 541 + - `pull_request`: The workflow should run every time a 542 + pull request is made or updated. 543 + - `manual`: The workflow can be triggered manually. 544 + - `branch`: Defines which branches the workflow should run 545 + for. If used with the `push` event, commits to the 546 + branch(es) listed here will trigger the workflow. If used 547 + with the `pull_request` event, updates to pull requests 548 + targeting the branch(es) listed here will trigger the 549 + workflow. This field has no effect with the `manual` 550 + event. Supports glob patterns using `*` and `**` (e.g., 551 + `main`, `develop`, `release-*`). Either `branch` or `tag` 552 + (or both) must be specified for `push` events. 553 + - `tag`: Defines which tags the workflow should run for. 554 + Only used with the `push` event - when tags matching the 555 + pattern(s) listed here are pushed, the workflow will 556 + trigger. This field has no effect with `pull_request` or 557 + `manual` events. Supports glob patterns using `*` and `**` 558 + (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or 559 + `tag` (or both) must be specified for `push` events. 560 + 561 + For example, if you'd like to define a workflow that runs 562 + when commits are pushed to the `main` and `develop` 563 + branches, or when pull requests that target the `main` 564 + branch are updated, or manually, you can do so with: 565 + 566 + ```yaml 567 + when: 568 + - event: ["push", "manual"] 569 + branch: ["main", "develop"] 570 + - event: ["pull_request"] 571 + branch: ["main"] 572 + ``` 573 + 574 + You can also trigger workflows on tag pushes. For instance, 575 + to run a deployment workflow when tags matching `v*` are 576 + pushed: 577 + 578 + ```yaml 579 + when: 580 + - event: ["push"] 581 + tag: ["v*"] 582 + ``` 583 + 584 + You can even combine branch and tag patterns in a single 585 + constraint (the workflow triggers if either matches): 586 + 587 + ```yaml 588 + when: 589 + - event: ["push"] 590 + branch: ["main", "release-*"] 591 + tag: ["v*", "stable"] 592 + ``` 593 + 594 + ### Engine 595 + 596 + Next is the engine on which the workflow should run, defined 597 + using the **required** `engine` field. The currently 598 + supported engines are: 599 + 600 + - `nixery`: This uses an instance of 601 + [Nixery](https://nixery.dev) to run steps, which allows 602 + you to add [dependencies](#dependencies) from 603 + Nixpkgs (https://github.com/NixOS/nixpkgs). You can 604 + search for packages on https://search.nixos.org, and 605 + there's a pretty good chance the package(s) you're looking 606 + for will be there. 607 + 608 + Example: 609 + 610 + ```yaml 611 + engine: "nixery" 612 + ``` 613 + 614 + ### Clone options 615 + 616 + When a workflow starts, the first step is to clone the 617 + repository. You can customize this behavior using the 618 + **optional** `clone` field. It has the following fields: 619 + 620 + - `skip`: Setting this to `true` will skip cloning the 621 + repository. This can be useful if your workflow is doing 622 + something that doesn't require anything from the 623 + repository itself. This is `false` by default. 624 + - `depth`: This sets the number of commits, or the "clone 625 + depth", to fetch from the repository. For example, if you 626 + set this to 2, the last 2 commits will be fetched. By 627 + default, the depth is set to 1, meaning only the most 628 + recent commit will be fetched, which is the commit that 629 + triggered the workflow. 630 + - `submodules`: If you use Git submodules 631 + (https://git-scm.com/book/en/v2/Git-Tools-Submodules) 632 + in your repository, setting this field to `true` will 633 + recursively fetch all submodules. This is `false` by 634 + default. 635 + 636 + The default settings are: 637 + 638 + ```yaml 639 + clone: 640 + skip: false 641 + depth: 1 642 + submodules: false 643 + ``` 644 + 645 + ### Dependencies 646 + 647 + Usually when you're running a workflow, you'll need 648 + additional dependencies. The `dependencies` field lets you 649 + define which dependencies to get, and from where. It's a 650 + key-value map, with the key being the registry to fetch 651 + dependencies from, and the value being the list of 652 + dependencies to fetch. 653 + 654 + Say you want to fetch Node.js and Go from `nixpkgs`, and a 655 + package called `my_pkg` you've made from your own registry 656 + at your repository at 657 + `https://tangled.org/@example.com/my_pkg`. You can define 658 + those dependencies like so: 659 + 660 + ```yaml 661 + dependencies: 662 + # nixpkgs 663 + nixpkgs: 664 + - nodejs 665 + - go 666 + # unstable 667 + nixpkgs/nixpkgs-unstable: 668 + - bun 669 + # custom registry 670 + git+https://tangled.org/@example.com/my_pkg: 671 + - my_pkg 672 + ``` 673 + 674 + Now these dependencies are available to use in your 675 + workflow! 676 + 677 + ### Environment 678 + 679 + The `environment` field allows you define environment 680 + variables that will be available throughout the entire 681 + workflow. **Do not put secrets here, these environment 682 + variables are visible to anyone viewing the repository. You 683 + can add secrets for pipelines in your repository's 684 + settings.** 685 + 686 + Example: 687 + 688 + ```yaml 689 + environment: 690 + GOOS: "linux" 691 + GOARCH: "arm64" 692 + NODE_ENV: "production" 693 + MY_ENV_VAR: "MY_ENV_VALUE" 694 + ``` 695 + 696 + ### Steps 697 + 698 + The `steps` field allows you to define what steps should run 699 + in the workflow. It's a list of step objects, each with the 700 + following fields: 701 + 702 + - `name`: This field allows you to give your step a name. 703 + This name is visible in your workflow runs, and is used to 704 + describe what the step is doing. 705 + - `command`: This field allows you to define a command to 706 + run in that step. The step is run in a Bash shell, and the 707 + logs from the command will be visible in the pipelines 708 + page on the Tangled website. The 709 + [dependencies](#dependencies) you added will be available 710 + to use here. 711 + - `environment`: Similar to the global 712 + [environment](#environment) config, this **optional** 713 + field is a key-value map that allows you to set 714 + environment variables for the step. **Do not put secrets 715 + here, these environment variables are visible to anyone 716 + viewing the repository. You can add secrets for pipelines 717 + in your repository's settings.** 718 + 719 + Example: 720 + 721 + ```yaml 722 + steps: 723 + - name: "Build backend" 724 + command: "go build" 725 + environment: 726 + GOOS: "darwin" 727 + GOARCH: "arm64" 728 + - name: "Build frontend" 729 + command: "npm run build" 730 + environment: 731 + NODE_ENV: "production" 732 + ``` 733 + 734 + ### Complete workflow 735 + 736 + ```yaml 737 + # .tangled/workflows/build.yml 738 + 739 + when: 740 + - event: ["push", "manual"] 741 + branch: ["main", "develop"] 742 + - event: ["pull_request"] 743 + branch: ["main"] 744 + 745 + engine: "nixery" 746 + 747 + # using the default values 748 + clone: 749 + skip: false 750 + depth: 1 751 + submodules: false 752 + 753 + dependencies: 754 + # nixpkgs 755 + nixpkgs: 756 + - nodejs 757 + - go 758 + # custom registry 759 + git+https://tangled.org/@example.com/my_pkg: 760 + - my_pkg 761 + 762 + environment: 763 + GOOS: "linux" 764 + GOARCH: "arm64" 765 + NODE_ENV: "production" 766 + MY_ENV_VAR: "MY_ENV_VALUE" 767 + 768 + steps: 769 + - name: "Build backend" 770 + command: "go build" 771 + environment: 772 + GOOS: "darwin" 773 + GOARCH: "arm64" 774 + - name: "Build frontend" 775 + command: "npm run build" 776 + environment: 777 + NODE_ENV: "production" 778 + ``` 779 + 780 + If you want another example of a workflow, you can look at 781 + the one [Tangled uses to build the 782 + project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml). 783 + 784 + ## Self-hosting guide 785 + 786 + ### Prerequisites 787 + 788 + * Go 789 + * Docker (the only supported backend currently) 790 + 791 + ### Configuration 792 + 793 + Spindle is configured using environment variables. The following environment variables are available: 794 + 795 + * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 796 + * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 797 + * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 798 + * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 799 + * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 800 + * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 801 + * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 802 + * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 803 + * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 804 + 805 + ### Running spindle 806 + 807 + 1. **Set the environment variables.** For example: 808 + 809 + ```shell 810 + export SPINDLE_SERVER_HOSTNAME="your-hostname" 811 + export SPINDLE_SERVER_OWNER="your-did" 812 + ``` 813 + 814 + 2. **Build the Spindle binary.** 815 + 816 + ```shell 817 + cd core 818 + go mod download 819 + go build -o cmd/spindle/spindle cmd/spindle/main.go 820 + ``` 821 + 822 + 3. **Create the log directory.** 823 + 824 + ```shell 825 + sudo mkdir -p /var/log/spindle 826 + sudo chown $USER:$USER -R /var/log/spindle 827 + ``` 828 + 829 + 4. **Run the Spindle binary.** 830 + 831 + ```shell 832 + ./cmd/spindle/spindle 833 + ``` 834 + 835 + Spindle will now start, connect to the Jetstream server, and begin processing pipelines. 836 + 837 + ## Architecture 838 + 839 + Spindle is a small CI runner service. Here's a high-level overview of how it operates: 840 + 841 + * Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 842 + [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 843 + * When a new repo record comes through (typically when you add a spindle to a 844 + repo from the settings), spindle then resolves the underlying knot and 845 + subscribes to repo events (see: 846 + [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 847 + * The spindle engine then handles execution of the pipeline, with results and 848 + logs beamed on the spindle event stream over WebSocket 849 + 850 + ### The engine 851 + 852 + At present, the only supported backend is Docker (and Podman, if Docker 853 + compatibility is enabled, so that `/run/docker.sock` is created). spindle 854 + executes each step in the pipeline in a fresh container, with state persisted 855 + across steps within the `/tangled/workspace` directory. 856 + 857 + The base image for the container is constructed on the fly using 858 + [Nixery](https://nixery.dev), which is handy for caching layers for frequently 859 + used packages. 860 + 861 + The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines). 862 + 863 + ## Secrets with openbao 864 + 865 + This document covers setting up spindle to use OpenBao for secrets 866 + management via OpenBao Proxy instead of the default SQLite backend. 867 + 868 + ### Overview 869 + 870 + Spindle now uses OpenBao Proxy for secrets management. The proxy handles 871 + authentication automatically using AppRole credentials, while spindle 872 + connects to the local proxy instead of directly to the OpenBao server. 873 + 874 + This approach provides better security, automatic token renewal, and 875 + simplified application code. 876 + 877 + ### Installation 878 + 879 + Install OpenBao from Nixpkgs: 880 + 881 + ```bash 882 + nix shell nixpkgs#openbao # for a local server 883 + ``` 884 + 885 + ### Setup 886 + 887 + The setup process can is documented for both local development and production. 888 + 889 + #### Local development 890 + 891 + Start OpenBao in dev mode: 892 + 893 + ```bash 894 + bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 895 + ``` 896 + 897 + This starts OpenBao on `http://localhost:8201` with a root token. 898 + 899 + Set up environment for bao CLI: 900 + 901 + ```bash 902 + export BAO_ADDR=http://localhost:8200 903 + export BAO_TOKEN=root 904 + ``` 905 + 906 + #### Production 907 + 908 + You would typically use a systemd service with a 909 + configuration file. Refer to 910 + [@tangled.org/infra](https://tangled.org/@tangled.org/infra) 911 + for how this can be achieved using Nix. 912 + 913 + Then, initialize the bao server: 914 + 915 + ```bash 916 + bao operator init -key-shares=1 -key-threshold=1 917 + ``` 918 + 919 + This will print out an unseal key and a root key. Save them 920 + somewhere (like a password manager). Then unseal the vault 921 + to begin setting it up: 922 + 923 + ```bash 924 + bao operator unseal <unseal_key> 925 + ``` 926 + 927 + All steps below remain the same across both dev and 928 + production setups. 929 + 930 + #### Configure openbao server 931 + 932 + Create the spindle KV mount: 933 + 934 + ```bash 935 + bao secrets enable -path=spindle -version=2 kv 936 + ``` 937 + 938 + Set up AppRole authentication and policy: 939 + 940 + Create a policy file `spindle-policy.hcl`: 941 + 942 + ```hcl 943 + # Full access to spindle KV v2 data 944 + path "spindle/data/*" { 945 + capabilities = ["create", "read", "update", "delete"] 946 + } 947 + 948 + # Access to metadata for listing and management 949 + path "spindle/metadata/*" { 950 + capabilities = ["list", "read", "delete", "update"] 951 + } 952 + 953 + # Allow listing at root level 954 + path "spindle/" { 955 + capabilities = ["list"] 956 + } 957 + 958 + # Required for connection testing and health checks 959 + path "auth/token/lookup-self" { 960 + capabilities = ["read"] 961 + } 962 + ``` 963 + 964 + Apply the policy and create an AppRole: 965 + 966 + ```bash 967 + bao policy write spindle-policy spindle-policy.hcl 968 + bao auth enable approle 969 + bao write auth/approle/role/spindle \ 970 + token_policies="spindle-policy" \ 971 + token_ttl=1h \ 972 + token_max_ttl=4h \ 973 + bind_secret_id=true \ 974 + secret_id_ttl=0 \ 975 + secret_id_num_uses=0 976 + ``` 977 + 978 + Get the credentials: 979 + 980 + ```bash 981 + # Get role ID (static) 982 + ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 983 + 984 + # Generate secret ID 985 + SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 986 + 987 + echo "Role ID: $ROLE_ID" 988 + echo "Secret ID: $SECRET_ID" 989 + ``` 990 + 991 + #### Create proxy configuration 992 + 993 + Create the credential files: 994 + 995 + ```bash 996 + # Create directory for OpenBao files 997 + mkdir -p /tmp/openbao 998 + 999 + # Save credentials 1000 + echo "$ROLE_ID" > /tmp/openbao/role-id 1001 + echo "$SECRET_ID" > /tmp/openbao/secret-id 1002 + chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 1003 + ``` 1004 + 1005 + Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 1006 + 1007 + ```hcl 1008 + # OpenBao server connection 1009 + vault { 1010 + address = "http://localhost:8200" 1011 + } 1012 + 1013 + # Auto-Auth using AppRole 1014 + auto_auth { 1015 + method "approle" { 1016 + mount_path = "auth/approle" 1017 + config = { 1018 + role_id_file_path = "/tmp/openbao/role-id" 1019 + secret_id_file_path = "/tmp/openbao/secret-id" 1020 + } 1021 + } 1022 + 1023 + # Optional: write token to file for debugging 1024 + sink "file" { 1025 + config = { 1026 + path = "/tmp/openbao/token" 1027 + mode = 0640 1028 + } 1029 + } 1030 + } 1031 + 1032 + # Proxy listener for spindle 1033 + listener "tcp" { 1034 + address = "127.0.0.1:8201" 1035 + tls_disable = true 1036 + } 1037 + 1038 + # Enable API proxy with auto-auth token 1039 + api_proxy { 1040 + use_auto_auth_token = true 1041 + } 1042 + 1043 + # Enable response caching 1044 + cache { 1045 + use_auto_auth_token = true 1046 + } 1047 + 1048 + # Logging 1049 + log_level = "info" 1050 + ``` 1051 + 1052 + #### Start the proxy 1053 + 1054 + Start OpenBao Proxy: 1055 + 1056 + ```bash 1057 + bao proxy -config=/tmp/openbao/proxy.hcl 1058 + ``` 1059 + 1060 + The proxy will authenticate with OpenBao and start listening on 1061 + `127.0.0.1:8201`. 1062 + 1063 + #### Configure spindle 1064 + 1065 + Set these environment variables for spindle: 1066 + 1067 + ```bash 1068 + export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 1069 + export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 1070 + export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 1071 + ``` 1072 + 1073 + On startup, spindle will now connect to the local proxy, 1074 + which handles all authentication automatically. 1075 + 1076 + ### Production setup for proxy 1077 + 1078 + For production, you'll want to run the proxy as a service: 1079 + 1080 + Place your production configuration in 1081 + `/etc/openbao/proxy.hcl` with proper TLS settings for the 1082 + vault connection. 1083 + 1084 + ### Verifying setup 1085 + 1086 + Test the proxy directly: 1087 + 1088 + ```bash 1089 + # Check proxy health 1090 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 1091 + 1092 + # Test token lookup through proxy 1093 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 1094 + ``` 1095 + 1096 + Test OpenBao operations through the server: 1097 + 1098 + ```bash 1099 + # List all secrets 1100 + bao kv list spindle/ 1101 + 1102 + # Add a test secret via the spindle API, then check it exists 1103 + bao kv list spindle/repos/ 1104 + 1105 + # Get a specific secret 1106 + bao kv get spindle/repos/your_repo_path/SECRET_NAME 1107 + ``` 1108 + 1109 + ### How it works 1110 + 1111 + - Spindle connects to OpenBao Proxy on localhost (typically 1112 + port 8200 or 8201) 1113 + - The proxy authenticates with OpenBao using AppRole 1114 + credentials 1115 + - All spindle requests go through the proxy, which injects 1116 + authentication tokens 1117 + - Secrets are stored at 1118 + `spindle/repos/{sanitized_repo_path}/{secret_key}` 1119 + - Repository paths like `did:plc:alice/myrepo` become 1120 + `did_plc_alice_myrepo` 1121 + - The proxy handles all token renewal automatically 1122 + - Spindle no longer manages tokens or authentication 1123 + directly 1124 + 1125 + ### Troubleshooting 1126 + 1127 + **Connection refused**: Check that the OpenBao Proxy is 1128 + running and listening on the configured address. 1129 + 1130 + **403 errors**: Verify the AppRole credentials are correct 1131 + and the policy has the necessary permissions. 1132 + 1133 + **404 route errors**: The spindle KV mount probably doesn't 1134 + existโ€”run the mount creation step again. 1135 + 1136 + **Proxy authentication failures**: Check the proxy logs and 1137 + verify the role-id and secret-id files are readable and 1138 + contain valid credentials. 1139 + 1140 + **Secret not found after writing**: This can indicate policy 1141 + permission issues. Verify the policy includes both 1142 + `spindle/data/*` and `spindle/metadata/*` paths with 1143 + appropriate capabilities. 1144 + 1145 + Check proxy logs: 1146 + 1147 + ```bash 1148 + # If running as systemd service 1149 + journalctl -u openbao-proxy -f 1150 + 1151 + # If running directly, check the console output 1152 + ``` 1153 + 1154 + Test AppRole authentication manually: 1155 + 1156 + ```bash 1157 + bao write auth/approle/login \ 1158 + role_id="$(cat /tmp/openbao/role-id)" \ 1159 + secret_id="$(cat /tmp/openbao/secret-id)" 1160 + ``` 1161 + 1162 + # Migrating knots and spindles 1163 + 1164 + Sometimes, non-backwards compatible changes are made to the 1165 + knot/spindle XRPC APIs. If you host a knot or a spindle, you 1166 + will need to follow this guide to upgrade. Typically, this 1167 + only requires you to deploy the newest version. 1168 + 1169 + This document is laid out in reverse-chronological order. 1170 + Newer migration guides are listed first, and older guides 1171 + are further down the page. 1172 + 1173 + ## Upgrading from v1.8.x 1174 + 1175 + After v1.8.2, the HTTP API for knots and spindles has been 1176 + deprecated and replaced with XRPC. Repositories on outdated 1177 + knots will not be viewable from the appview. Upgrading is 1178 + straightforward however. 1179 + 1180 + For knots: 1181 + 1182 + - Upgrade to the latest tag (v1.9.0 or above) 1183 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 1184 + hit the "retry" button to verify your knot 1185 + 1186 + For spindles: 1187 + 1188 + - Upgrade to the latest tag (v1.9.0 or above) 1189 + - Head to the [spindle 1190 + dashboard](https://tangled.org/settings/spindles) and hit the 1191 + "retry" button to verify your spindle 1192 + 1193 + ## Upgrading from v1.7.x 1194 + 1195 + After v1.7.0, knot secrets have been deprecated. You no 1196 + longer need a secret from the appview to run a knot. All 1197 + authorized commands to knots are managed via [Inter-Service 1198 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 1199 + Knots will be read-only until upgraded. 1200 + 1201 + Upgrading is quite easy, in essence: 1202 + 1203 + - `KNOT_SERVER_SECRET` is no more, you can remove this 1204 + environment variable entirely 1205 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 1206 + your DID. You can find your DID in the 1207 + [settings](https://tangled.org/settings) page. 1208 + - Restart your knot once you have replaced the environment 1209 + variable 1210 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 1211 + hit the "retry" button to verify your knot. This simply 1212 + writes a `sh.tangled.knot` record to your PDS. 1213 + 1214 + If you use the nix module, simply bump the flake to the 1215 + latest revision, and change your config block like so: 1216 + 1217 + ```diff 1218 + services.tangled.knot = { 1219 + enable = true; 1220 + server = { 1221 + - secretFile = /path/to/secret; 1222 + + owner = "did:plc:foo"; 1223 + }; 1224 + }; 1225 + ``` 1226 + 1227 + # Hacking on Tangled 1228 + 1229 + We highly recommend [installing 1230 + Nix](https://nixos.org/download/) (the package manager) 1231 + before working on the codebase. The Nix flake provides a lot 1232 + of helpers to get started and most importantly, builds and 1233 + dev shells are entirely deterministic. 1234 + 1235 + To set up your dev environment: 1236 + 1237 + ```bash 1238 + nix develop 1239 + ``` 1240 + 1241 + Non-Nix users can look at the `devShell` attribute in the 1242 + `flake.nix` file to determine necessary dependencies. 1243 + 1244 + ## Running the appview 1245 + 1246 + The Nix flake also exposes a few `app` attributes (run `nix 1247 + flake show` to see a full list of what the flake provides), 1248 + one of the apps runs the appview with the `air` 1249 + live-reloader: 1250 + 1251 + ```bash 1252 + TANGLED_DEV=true nix run .#watch-appview 1253 + 1254 + # TANGLED_DB_PATH might be of interest to point to 1255 + # different sqlite DBs 1256 + 1257 + # in a separate shell, you can live-reload tailwind 1258 + nix run .#watch-tailwind 1259 + ``` 1260 + 1261 + To authenticate with the appview, you will need Redis and 1262 + OAuth JWKs to be set up: 1263 + 1264 + ``` 1265 + # OAuth JWKs should already be set up by the Nix devshell: 1266 + echo $TANGLED_OAUTH_CLIENT_SECRET 1267 + z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 1268 + 1269 + echo $TANGLED_OAUTH_CLIENT_KID 1270 + 1761667908 1271 + 1272 + # if not, you can set it up yourself: 1273 + goat key generate -t P-256 1274 + Key Type: P-256 / secp256r1 / ES256 private key 1275 + Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 1276 + z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 1277 + Public Key (DID Key Syntax): share or publish this (eg, in DID document) 1278 + did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 1279 + 1280 + # the secret key from above 1281 + export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 1282 + 1283 + # Run Redis in a new shell to store OAuth sessions 1284 + redis-server 1285 + ``` 1286 + 1287 + ## Running knots and spindles 1288 + 1289 + An end-to-end knot setup requires setting up a machine with 1290 + `sshd`, `AuthorizedKeysCommand`, and a Git user, which is 1291 + quite cumbersome. So the Nix flake provides a 1292 + `nixosConfiguration` to do so. 1293 + 1294 + <details> 1295 + <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary> 1296 + 1297 + In order to build Tangled's dev VM on macOS, you will 1298 + first need to set up a Linux Nix builder. The recommended 1299 + way to do so is to run a [`darwin.linux-builder` 1300 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 1301 + and to register it in `nix.conf` as a builder for Linux 1302 + with the same architecture as your Mac (`linux-aarch64` if 1303 + you are using Apple Silicon). 1304 + 1305 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 1306 + > the Tangled repo so that it doesn't conflict with the other VM. For example, 1307 + > you can do 1308 + > 1309 + > ```shell 1310 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 1311 + > ``` 1312 + > 1313 + > to store the builder VM in a temporary dir. 1314 + > 1315 + > You should read and follow [all the other intructions][darwin builder vm] to 1316 + > avoid subtle problems. 1317 + 1318 + Alternatively, you can use any other method to set up a 1319 + Linux machine with Nix installed that you can `sudo ssh` 1320 + into (in other words, root user on your Mac has to be able 1321 + to ssh into the Linux machine without entering a password) 1322 + and that has the same architecture as your Mac. See 1323 + [remote builder 1324 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 1325 + for how to register such a builder in `nix.conf`. 1326 + 1327 + > WARNING: If you'd like to use 1328 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 1329 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 1330 + > ssh` works can be tricky. It seems to be [possible with 1331 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 1332 + 1333 + </details> 1334 + 1335 + To begin, grab your DID from http://localhost:3000/settings. 1336 + Then, set `TANGLED_VM_KNOT_OWNER` and 1337 + `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 1338 + lightweight NixOS VM like so: 1339 + 1340 + ```bash 1341 + nix run --impure .#vm 1342 + 1343 + # type `poweroff` at the shell to exit the VM 1344 + ``` 1345 + 1346 + This starts a knot on port 6444, a spindle on port 6555 1347 + with `ssh` exposed on port 2222. 1348 + 1349 + Once the services are running, head to 1350 + http://localhost:3000/settings/knots and hit "Verify". It should 1351 + verify the ownership of the services instantly if everything 1352 + went smoothly. 1353 + 1354 + You can push repositories to this VM with this ssh config 1355 + block on your main machine: 1356 + 1357 + ```bash 1358 + Host nixos-shell 1359 + Hostname localhost 1360 + Port 2222 1361 + User git 1362 + IdentityFile ~/.ssh/my_tangled_key 1363 + ``` 1364 + 1365 + Set up a remote called `local-dev` on a git repo: 1366 + 1367 + ```bash 1368 + git remote add local-dev git@nixos-shell:user/repo 1369 + git push local-dev main 1370 + ``` 1371 + 1372 + The above VM should already be running a spindle on 1373 + `localhost:6555`. Head to http://localhost:3000/settings/spindles and 1374 + hit "Verify". You can then configure each repository to use 1375 + this spindle and run CI jobs. 1376 + 1377 + Of interest when debugging spindles: 1378 + 1379 + ``` 1380 + # Service logs from journald: 1381 + journalctl -xeu spindle 1382 + 1383 + # CI job logs from disk: 1384 + ls /var/log/spindle 1385 + 1386 + # Debugging spindle database: 1387 + sqlite3 /var/lib/spindle/spindle.db 1388 + 1389 + # litecli has a nicer REPL interface: 1390 + litecli /var/lib/spindle/spindle.db 1391 + ``` 1392 + 1393 + If for any reason you wish to disable either one of the 1394 + services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 1395 + `services.tangled.spindle.enable` (or 1396 + `services.tangled.knot.enable`) to `false`. 1397 + 1398 + # Contribution guide 1399 + 1400 + ## Commit guidelines 1401 + 1402 + We follow a commit style similar to the Go project. Please keep commits: 1403 + 1404 + * **atomic**: each commit should represent one logical change 1405 + * **descriptive**: the commit message should clearly describe what the 1406 + change does and why it's needed 1407 + 1408 + ### Message format 1409 + 1410 + ``` 1411 + <service/top-level directory>/<affected package/directory>: <short summary of change> 1412 + 1413 + Optional longer description can go here, if necessary. Explain what the 1414 + change does and why, especially if not obvious. Reference relevant 1415 + issues or PRs when applicable. These can be links for now since we don't 1416 + auto-link issues/PRs yet. 1417 + ``` 1418 + 1419 + Here are some examples: 1420 + 1421 + ``` 1422 + appview/state: fix token expiry check in middleware 1423 + 1424 + The previous check did not account for clock drift, leading to premature 1425 + token invalidation. 1426 + ``` 1427 + 1428 + ``` 1429 + knotserver/git/service: improve error checking in upload-pack 1430 + ``` 1431 + 1432 + 1433 + ### General notes 1434 + 1435 + - PRs get merged "as-is" (fast-forward)โ€”like applying a patch-series 1436 + using `git am`. At present, there is no squashingโ€”so please author 1437 + your commits as they would appear on `master`, following the above 1438 + guidelines. 1439 + - If there is a lot of nesting, for example "appview: 1440 + pages/templates/repo/fragments: ...", these can be truncated down to 1441 + just "appview: repo/fragments: ...". If the change affects a lot of 1442 + subdirectories, you may abbreviate to just the top-level names, e.g. 1443 + "appview: ..." or "knotserver: ...". 1444 + - Keep commits lowercased with no trailing period. 1445 + - Use the imperative mood in the summary line (e.g., "fix bug" not 1446 + "fixed bug" or "fixes bug"). 1447 + - Try to keep the summary line under 72 characters, but we aren't too 1448 + fussed about this. 1449 + - Follow the same formatting for PR titles if filled manually. 1450 + - Don't include unrelated changes in the same commit. 1451 + - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 1452 + before submitting if necessary. 1453 + 1454 + ## Code formatting 1455 + 1456 + We use a variety of tools to format our code, and multiplex them with 1457 + [`treefmt`](https://treefmt.com). All you need to do to format your changes 1458 + is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 1459 + 1460 + ## Proposals for bigger changes 1461 + 1462 + Small fixes like typos, minor bugs, or trivial refactors can be 1463 + submitted directly as PRs. 1464 + 1465 + For larger changesโ€”especially those introducing new features, significant 1466 + refactoring, or altering system behaviorโ€”please open a proposal first. This 1467 + helps us evaluate the scope, design, and potential impact before implementation. 1468 + 1469 + Create a new issue titled: 1470 + 1471 + ``` 1472 + proposal: <affected scope>: <summary of change> 1473 + ``` 1474 + 1475 + In the description, explain: 1476 + 1477 + - What the change is 1478 + - Why it's needed 1479 + - How you plan to implement it (roughly) 1480 + - Any open questions or tradeoffs 1481 + 1482 + We'll use the issue thread to discuss and refine the idea before moving 1483 + forward. 1484 + 1485 + ## Developer Certificate of Origin (DCO) 1486 + 1487 + We require all contributors to certify that they have the right to 1488 + submit the code they're contributing. To do this, we follow the 1489 + [Developer Certificate of Origin 1490 + (DCO)](https://developercertificate.org/). 1491 + 1492 + By signing your commits, you're stating that the contribution is your 1493 + own work, or that you have the right to submit it under the project's 1494 + license. This helps us keep things clean and legally sound. 1495 + 1496 + To sign your commit, just add the `-s` flag when committing: 1497 + 1498 + ```sh 1499 + git commit -s -m "your commit message" 1500 + ``` 1501 + 1502 + This appends a line like: 1503 + 1504 + ``` 1505 + Signed-off-by: Your Name <your.email@example.com> 1506 + ``` 1507 + 1508 + We won't merge commits if they aren't signed off. If you forget, you can 1509 + amend the last commit like this: 1510 + 1511 + ```sh 1512 + git commit --amend -s 1513 + ``` 1514 + 1515 + If you're submitting a PR with multiple commits, make sure each one is 1516 + signed. 1517 + 1518 + For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 1519 + to make it sign off commits in the tangled repo: 1520 + 1521 + ```shell 1522 + # Safety check, should say "No matching config key..." 1523 + jj config list templates.commit_trailers 1524 + # The command below may need to be adjusted if the command above returned something. 1525 + jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 1526 + ``` 1527 + 1528 + Refer to the [jujutsu 1529 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 1530 + for more information.
-136
docs/contributing.md
··· 1 - # tangled contributing guide 2 - 3 - ## commit guidelines 4 - 5 - We follow a commit style similar to the Go project. Please keep commits: 6 - 7 - * **atomic**: each commit should represent one logical change 8 - * **descriptive**: the commit message should clearly describe what the 9 - change does and why it's needed 10 - 11 - ### message format 12 - 13 - ``` 14 - <service/top-level directory>/<affected package/directory>: <short summary of change> 15 - 16 - 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 - ``` 22 - 23 - Here are some examples: 24 - 25 - ``` 26 - appview/state: fix token expiry check in middleware 27 - 28 - The previous check did not account for clock drift, leading to premature 29 - token invalidation. 30 - ``` 31 - 32 - ``` 33 - knotserver/git/service: improve error checking in upload-pack 34 - ``` 35 - 36 - 37 - ### general notes 38 - 39 - - PRs get merged "as-is" (fast-forward) -- like applying a patch-series 40 - using `git am`. At present, there is no squashing -- so please author 41 - your commits as they would appear on `master`, following the above 42 - guidelines. 43 - - If there is a lot of nesting, for example "appview: 44 - pages/templates/repo/fragments: ...", these can be truncated down to 45 - just "appview: repo/fragments: ...". If the change affects a lot of 46 - subdirectories, you may abbreviate to just the top-level names, e.g. 47 - "appview: ..." or "knotserver: ...". 48 - - Keep commits lowercased with no trailing period. 49 - - Use the imperative mood in the summary line (e.g., "fix bug" not 50 - "fixed bug" or "fixes bug"). 51 - - Try to keep the summary line under 72 characters, but we aren't too 52 - fussed about this. 53 - - Follow the same formatting for PR titles if filled manually. 54 - - Don't include unrelated changes in the same commit. 55 - - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 - before submitting if necessary. 57 - 58 - ## code formatting 59 - 60 - We use a variety of tools to format our code, and multiplex them with 61 - [`treefmt`](https://treefmt.com): all you need to do to format your changes 62 - is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 63 - 64 - ## proposals for bigger changes 65 - 66 - Small fixes like typos, minor bugs, or trivial refactors can be 67 - submitted directly as PRs. 68 - 69 - For larger changesโ€”especially those introducing new features, significant 70 - refactoring, or altering system behaviorโ€”please open a proposal first. This 71 - helps us evaluate the scope, design, and potential impact before implementation. 72 - 73 - ### proposal format 74 - 75 - Create a new issue titled: 76 - 77 - ``` 78 - proposal: <affected scope>: <summary of change> 79 - ``` 80 - 81 - In the description, explain: 82 - 83 - - What the change is 84 - - Why it's needed 85 - - How you plan to implement it (roughly) 86 - - Any open questions or tradeoffs 87 - 88 - We'll use the issue thread to discuss and refine the idea before moving 89 - forward. 90 - 91 - ## developer certificate of origin (DCO) 92 - 93 - We require all contributors to certify that they have the right to 94 - submit the code they're contributing. To do this, we follow the 95 - [Developer Certificate of Origin 96 - (DCO)](https://developercertificate.org/). 97 - 98 - By signing your commits, you're stating that the contribution is your 99 - own work, or that you have the right to submit it under the project's 100 - license. This helps us keep things clean and legally sound. 101 - 102 - To sign your commit, just add the `-s` flag when committing: 103 - 104 - ```sh 105 - git commit -s -m "your commit message" 106 - ``` 107 - 108 - This appends a line like: 109 - 110 - ``` 111 - Signed-off-by: Your Name <your.email@example.com> 112 - ``` 113 - 114 - We won't merge commits if they aren't signed off. If you forget, you can 115 - amend the last commit like this: 116 - 117 - ```sh 118 - git commit --amend -s 119 - ``` 120 - 121 - If you're submitting a PR with multiple commits, make sure each one is 122 - signed. 123 - 124 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 125 - to make it sign off commits in the tangled repo: 126 - 127 - ```shell 128 - # Safety check, should say "No matching config key..." 129 - jj config list templates.commit_trailers 130 - # The command below may need to be adjusted if the command above returned something. 131 - jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 132 - ``` 133 - 134 - Refer to the [jj 135 - documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 136 - for more information.
···
-172
docs/hacking.md
··· 1 - # hacking on tangled 2 - 3 - We highly recommend [installing 4 - nix](https://nixos.org/download/) (the package manager) 5 - before working on the codebase. The nix flake provides a lot 6 - of helpers to get started and most importantly, builds and 7 - dev shells are entirely deterministic. 8 - 9 - To set up your dev environment: 10 - 11 - ```bash 12 - nix develop 13 - ``` 14 - 15 - Non-nix users can look at the `devShell` attribute in the 16 - `flake.nix` file to determine necessary dependencies. 17 - 18 - ## running the appview 19 - 20 - The nix flake also exposes a few `app` attributes (run `nix 21 - flake show` to see a full list of what the flake provides), 22 - one of the apps runs the appview with the `air` 23 - live-reloader: 24 - 25 - ```bash 26 - TANGLED_DEV=true nix run .#watch-appview 27 - 28 - # TANGLED_DB_PATH might be of interest to point to 29 - # different sqlite DBs 30 - 31 - # in a separate shell, you can live-reload tailwind 32 - nix run .#watch-tailwind 33 - ``` 34 - 35 - To authenticate with the appview, you will need redis and 36 - OAUTH JWKs to be setup: 37 - 38 - ``` 39 - # oauth jwks should already be setup by the nix devshell: 40 - echo $TANGLED_OAUTH_CLIENT_SECRET 41 - z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 42 - 43 - echo $TANGLED_OAUTH_CLIENT_KID 44 - 1761667908 45 - 46 - # if not, you can set it up yourself: 47 - goat key generate -t P-256 48 - Key Type: P-256 / secp256r1 / ES256 private key 49 - Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 50 - z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 51 - Public Key (DID Key Syntax): share or publish this (eg, in DID document) 52 - did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 53 - 54 - # the secret key from above 55 - export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 56 - 57 - # run redis in at a new shell to store oauth sessions 58 - redis-server 59 - ``` 60 - 61 - ## running knots and spindles 62 - 63 - An end-to-end knot setup requires setting up a machine with 64 - `sshd`, `AuthorizedKeysCommand`, and git user, which is 65 - quite cumbersome. So the nix flake provides a 66 - `nixosConfiguration` to do so. 67 - 68 - <details> 69 - <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 70 - 71 - In order to build Tangled's dev VM on macOS, you will 72 - first need to set up a Linux Nix builder. The recommended 73 - way to do so is to run a [`darwin.linux-builder` 74 - VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 75 - and to register it in `nix.conf` as a builder for Linux 76 - with the same architecture as your Mac (`linux-aarch64` if 77 - you are using Apple Silicon). 78 - 79 - > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 80 - > the tangled repo so that it doesn't conflict with the other VM. For example, 81 - > you can do 82 - > 83 - > ```shell 84 - > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 85 - > ``` 86 - > 87 - > to store the builder VM in a temporary dir. 88 - > 89 - > You should read and follow [all the other intructions][darwin builder vm] to 90 - > avoid subtle problems. 91 - 92 - Alternatively, you can use any other method to set up a 93 - Linux machine with `nix` installed that you can `sudo ssh` 94 - into (in other words, root user on your Mac has to be able 95 - to ssh into the Linux machine without entering a password) 96 - and that has the same architecture as your Mac. See 97 - [remote builder 98 - instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 99 - for how to register such a builder in `nix.conf`. 100 - 101 - > WARNING: If you'd like to use 102 - > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 103 - > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 104 - > ssh` works can be tricky. It seems to be [possible with 105 - > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 106 - 107 - </details> 108 - 109 - To begin, grab your DID from http://localhost:3000/settings. 110 - Then, set `TANGLED_VM_KNOT_OWNER` and 111 - `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 112 - lightweight NixOS VM like so: 113 - 114 - ```bash 115 - nix run --impure .#vm 116 - 117 - # type `poweroff` at the shell to exit the VM 118 - ``` 119 - 120 - This starts a knot on port 6444, a spindle on port 6555 121 - with `ssh` exposed on port 2222. 122 - 123 - Once the services are running, head to 124 - http://localhost:3000/settings/knots and hit verify. It should 125 - verify the ownership of the services instantly if everything 126 - went smoothly. 127 - 128 - You can push repositories to this VM with this ssh config 129 - block on your main machine: 130 - 131 - ```bash 132 - Host nixos-shell 133 - Hostname localhost 134 - Port 2222 135 - User git 136 - IdentityFile ~/.ssh/my_tangled_key 137 - ``` 138 - 139 - Set up a remote called `local-dev` on a git repo: 140 - 141 - ```bash 142 - git remote add local-dev git@nixos-shell:user/repo 143 - git push local-dev main 144 - ``` 145 - 146 - ### running a spindle 147 - 148 - The above VM should already be running a spindle on 149 - `localhost:6555`. Head to http://localhost:3000/settings/spindles and 150 - hit verify. You can then configure each repository to use 151 - this spindle and run CI jobs. 152 - 153 - Of interest when debugging spindles: 154 - 155 - ``` 156 - # service logs from journald: 157 - journalctl -xeu spindle 158 - 159 - # CI job logs from disk: 160 - ls /var/log/spindle 161 - 162 - # debugging spindle db: 163 - sqlite3 /var/lib/spindle/spindle.db 164 - 165 - # litecli has a nicer REPL interface: 166 - litecli /var/lib/spindle/spindle.db 167 - ``` 168 - 169 - If for any reason you wish to disable either one of the 170 - services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 171 - `services.tangled.spindle.enable` (or 172 - `services.tangled.knot.enable`) to `false`.
···
+93
docs/highlight.theme
···
··· 1 + { 2 + "text-color": null, 3 + "background-color": null, 4 + "line-number-color": null, 5 + "line-number-background-color": null, 6 + "text-styles": { 7 + "Annotation": { 8 + "text-color": null, 9 + "background-color": null, 10 + "bold": false, 11 + "italic": true, 12 + "underline": false 13 + }, 14 + "ControlFlow": { 15 + "text-color": null, 16 + "background-color": null, 17 + "bold": true, 18 + "italic": false, 19 + "underline": false 20 + }, 21 + "Error": { 22 + "text-color": null, 23 + "background-color": null, 24 + "bold": true, 25 + "italic": false, 26 + "underline": false 27 + }, 28 + "Alert": { 29 + "text-color": null, 30 + "background-color": null, 31 + "bold": true, 32 + "italic": false, 33 + "underline": false 34 + }, 35 + "Preprocessor": { 36 + "text-color": null, 37 + "background-color": null, 38 + "bold": true, 39 + "italic": false, 40 + "underline": false 41 + }, 42 + "Information": { 43 + "text-color": null, 44 + "background-color": null, 45 + "bold": false, 46 + "italic": true, 47 + "underline": false 48 + }, 49 + "Warning": { 50 + "text-color": null, 51 + "background-color": null, 52 + "bold": false, 53 + "italic": true, 54 + "underline": false 55 + }, 56 + "Documentation": { 57 + "text-color": null, 58 + "background-color": null, 59 + "bold": false, 60 + "italic": true, 61 + "underline": false 62 + }, 63 + "DataType": { 64 + "text-color": "#8f4e8b", 65 + "background-color": null, 66 + "bold": false, 67 + "italic": false, 68 + "underline": false 69 + }, 70 + "Comment": { 71 + "text-color": null, 72 + "background-color": null, 73 + "bold": false, 74 + "italic": true, 75 + "underline": false 76 + }, 77 + "CommentVar": { 78 + "text-color": null, 79 + "background-color": null, 80 + "bold": false, 81 + "italic": true, 82 + "underline": false 83 + }, 84 + "Keyword": { 85 + "text-color": null, 86 + "background-color": null, 87 + "bold": true, 88 + "italic": false, 89 + "underline": false 90 + } 91 + } 92 + } 93 +
-214
docs/knot-hosting.md
··· 1 - # knot self-hosting guide 2 - 3 - So you want to run your own knot server? Great! Here are a few prerequisites: 4 - 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 - 2. A (sub)domain name. People generally use `knot.example.com`. 7 - 3. A valid SSL certificate for your domain. 8 - 9 - There's a couple of ways to get started: 10 - * NixOS: refer to 11 - [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix) 12 - * Docker: Documented at 13 - [@tangled.sh/knot-docker](https://tangled.sh/@tangled.sh/knot-docker) 14 - (community maintained: support is not guaranteed!) 15 - * Manual: Documented below. 16 - 17 - ## manual setup 18 - 19 - First, clone this repository: 20 - 21 - ``` 22 - git clone https://tangled.org/@tangled.org/core 23 - ``` 24 - 25 - Then, build the `knot` CLI. This is the knot administration and operation tool. 26 - For the purpose of this guide, we're only concerned with these subcommands: 27 - 28 - * `knot server`: the main knot server process, typically run as a 29 - supervised service 30 - * `knot guard`: handles role-based access control for git over SSH 31 - (you'll never have to run this yourself) 32 - * `knot keys`: fetches SSH keys associated with your knot; we'll use 33 - this to generate the SSH `AuthorizedKeysCommand` 34 - 35 - ``` 36 - cd core 37 - export CGO_ENABLED=1 38 - go build -o knot ./cmd/knot 39 - ``` 40 - 41 - Next, move the `knot` binary to a location owned by `root` -- 42 - `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 43 - 44 - ``` 45 - sudo mv knot /usr/local/bin/knot 46 - sudo chown root:root /usr/local/bin/knot 47 - ``` 48 - 49 - This is necessary because SSH `AuthorizedKeysCommand` requires [really 50 - specific permissions](https://stackoverflow.com/a/27638306). The 51 - `AuthorizedKeysCommand` specifies a command that is run by `sshd` to 52 - retrieve a user's public SSH keys dynamically for authentication. Let's 53 - set that up. 54 - 55 - ``` 56 - sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 57 - Match User git 58 - AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 59 - AuthorizedKeysCommandUser nobody 60 - EOF 61 - ``` 62 - 63 - Then, reload `sshd`: 64 - 65 - ``` 66 - sudo systemctl reload ssh 67 - ``` 68 - 69 - Next, create the `git` user. We'll use the `git` user's home directory 70 - to store repositories: 71 - 72 - ``` 73 - sudo adduser git 74 - ``` 75 - 76 - Create `/home/git/.knot.env` with the following, updating the values as 77 - necessary. The `KNOT_SERVER_OWNER` should be set to your 78 - DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 79 - 80 - ``` 81 - KNOT_REPO_SCAN_PATH=/home/git 82 - KNOT_SERVER_HOSTNAME=knot.example.com 83 - APPVIEW_ENDPOINT=https://tangled.sh 84 - KNOT_SERVER_OWNER=did:plc:foobar 85 - KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 86 - KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 87 - ``` 88 - 89 - If you run a Linux distribution that uses systemd, you can use the provided 90 - service file to run the server. Copy 91 - [`knotserver.service`](/systemd/knotserver.service) 92 - to `/etc/systemd/system/`. Then, run: 93 - 94 - ``` 95 - systemctl enable knotserver 96 - systemctl start knotserver 97 - ``` 98 - 99 - The last step is to configure a reverse proxy like Nginx or Caddy to front your 100 - knot. Here's an example configuration for Nginx: 101 - 102 - ``` 103 - server { 104 - listen 80; 105 - listen [::]:80; 106 - server_name knot.example.com; 107 - 108 - location / { 109 - proxy_pass http://localhost:5555; 110 - proxy_set_header Host $host; 111 - proxy_set_header X-Real-IP $remote_addr; 112 - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 113 - proxy_set_header X-Forwarded-Proto $scheme; 114 - } 115 - 116 - # wss endpoint for git events 117 - location /events { 118 - proxy_set_header X-Forwarded-For $remote_addr; 119 - proxy_set_header Host $http_host; 120 - proxy_set_header Upgrade websocket; 121 - proxy_set_header Connection Upgrade; 122 - proxy_pass http://localhost:5555; 123 - } 124 - # additional config for SSL/TLS go here. 125 - } 126 - 127 - ``` 128 - 129 - Remember to use Let's Encrypt or similar to procure a certificate for your 130 - knot domain. 131 - 132 - You should now have a running knot server! You can finalize 133 - your registration by hitting the `verify` button on the 134 - [/settings/knots](https://tangled.org/settings/knots) page. This simply creates 135 - a record on your PDS to announce the existence of the knot. 136 - 137 - ### custom paths 138 - 139 - (This section applies to manual setup only. Docker users should edit the mounts 140 - in `docker-compose.yml` instead.) 141 - 142 - Right now, the database and repositories of your knot lives in `/home/git`. You 143 - can move these paths if you'd like to store them in another folder. Be careful 144 - when adjusting these paths: 145 - 146 - * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 147 - any possible side effects. Remember to restart it once you're done. 148 - * Make backups before moving in case something goes wrong. 149 - * Make sure the `git` user can read and write from the new paths. 150 - 151 - #### database 152 - 153 - As an example, let's say the current database is at `/home/git/knotserver.db`, 154 - and we want to move it to `/home/git/database/knotserver.db`. 155 - 156 - Copy the current database to the new location. Make sure to copy the `.db-shm` 157 - and `.db-wal` files if they exist. 158 - 159 - ``` 160 - mkdir /home/git/database 161 - cp /home/git/knotserver.db* /home/git/database 162 - ``` 163 - 164 - In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 165 - the new file path (_not_ the directory): 166 - 167 - ``` 168 - KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 169 - ``` 170 - 171 - #### repositories 172 - 173 - As an example, let's say the repositories are currently in `/home/git`, and we 174 - want to move them into `/home/git/repositories`. 175 - 176 - Create the new folder, then move the existing repositories (if there are any): 177 - 178 - ``` 179 - mkdir /home/git/repositories 180 - # move all DIDs into the new folder; these will vary for you! 181 - mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 182 - ``` 183 - 184 - In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 185 - to the new directory: 186 - 187 - ``` 188 - KNOT_REPO_SCAN_PATH=/home/git/repositories 189 - ``` 190 - 191 - Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated 192 - repository path: 193 - 194 - ``` 195 - sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 196 - Match User git 197 - AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories 198 - AuthorizedKeysCommandUser nobody 199 - EOF 200 - ``` 201 - 202 - Make sure to restart your SSH server! 203 - 204 - #### MOTD (message of the day) 205 - 206 - To configure the MOTD used ("Welcome to this knot!" by default), edit the 207 - `/home/git/motd` file: 208 - 209 - ``` 210 - printf "Hi from this knot!\n" > /home/git/motd 211 - ``` 212 - 213 - Note that you should add a newline at the end if setting a non-empty message 214 - since the knot won't do this for you.
···
+6
docs/logo.html
···
··· 1 + <div class="flex items-center gap-2 w-fit mx-auto"> 2 + <span class="w-16 h-16 [&>svg]:w-full [&>svg]:h-full text-black dark:text-white"> 3 + ${ dolly.svg() } 4 + </span> 5 + <span class="font-bold text-4xl not-italic text-black dark:text-white">tangled</span> 6 + </div>
-59
docs/migrations.md
··· 1 - # Migrations 2 - 3 - This document is laid out in reverse-chronological order. 4 - Newer migration guides are listed first, and older guides 5 - are further down the page. 6 - 7 - ## Upgrading from v1.8.x 8 - 9 - After v1.8.2, the HTTP API for knot and spindles have been 10 - deprecated and replaced with XRPC. Repositories on outdated 11 - knots will not be viewable from the appview. Upgrading is 12 - straightforward however. 13 - 14 - For knots: 15 - 16 - - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.org/settings/knots) and 18 - hit the "retry" button to verify your knot 19 - 20 - For spindles: 21 - 22 - - Upgrade to latest tag (v1.9.0 or above) 23 - - Head to the [spindle 24 - dashboard](https://tangled.org/settings/spindles) and hit the 25 - "retry" button to verify your spindle 26 - 27 - ## Upgrading from v1.7.x 28 - 29 - After v1.7.0, knot secrets have been deprecated. You no 30 - longer need a secret from the appview to run a knot. All 31 - authorized commands to knots are managed via [Inter-Service 32 - Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 33 - Knots will be read-only until upgraded. 34 - 35 - Upgrading is quite easy, in essence: 36 - 37 - - `KNOT_SERVER_SECRET` is no more, you can remove this 38 - environment variable entirely 39 - - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 - your DID. You can find your DID in the 41 - [settings](https://tangled.org/settings) page. 42 - - Restart your knot once you have replaced the environment 43 - variable 44 - - Head to the [knot dashboard](https://tangled.org/settings/knots) and 45 - hit the "retry" button to verify your knot. This simply 46 - writes a `sh.tangled.knot` record to your PDS. 47 - 48 - If you use the nix module, simply bump the flake to the 49 - latest revision, and change your config block like so: 50 - 51 - ```diff 52 - services.tangled.knot = { 53 - enable = true; 54 - server = { 55 - - secretFile = /path/to/secret; 56 - + owner = "did:plc:foo"; 57 - }; 58 - }; 59 - ```
···
+3
docs/mode.html
···
··· 1 + <a class="px-4 py-2 mt-8 block text-center w-full rounded-sm shadow-sm border border-gray-200 dark:border-gray-700 no-underline hover:no-underline" href="$if(single-page)$/$else$/single-page.html$endif$"> 2 + $if(single-page)$View as multi-page$else$View as single-page$endif$ 3 + </a>
+7
docs/search.html
···
··· 1 + <form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full"> 2 + <input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]"> 3 + <label> 4 + <span style="display:none;">Search</span> 5 + <input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal"> 6 + </label> 7 + </form>
-25
docs/spindle/architecture.md
··· 1 - # spindle architecture 2 - 3 - Spindle is a small CI runner service. Here's a high level overview of how it operates: 4 - 5 - * listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 6 - [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 7 - * when a new repo record comes through (typically when you add a spindle to a 8 - repo from the settings), spindle then resolves the underlying knot and 9 - subscribes to repo events (see: 10 - [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 11 - * the spindle engine then handles execution of the pipeline, with results and 12 - logs beamed on the spindle event stream over wss 13 - 14 - ### the engine 15 - 16 - At present, the only supported backend is Docker (and Podman, if Docker 17 - compatibility is enabled, so that `/run/docker.sock` is created). Spindle 18 - executes each step in the pipeline in a fresh container, with state persisted 19 - across steps within the `/tangled/workspace` directory. 20 - 21 - The base image for the container is constructed on the fly using 22 - [Nixery](https://nixery.dev), which is handy for caching layers for frequently 23 - used packages. 24 - 25 - The pipeline manifest is [specified here](/docs/spindle/pipeline.md).
···
-52
docs/spindle/hosting.md
··· 1 - # spindle self-hosting guide 2 - 3 - ## prerequisites 4 - 5 - * Go 6 - * Docker (the only supported backend currently) 7 - 8 - ## configuration 9 - 10 - Spindle is configured using environment variables. The following environment variables are available: 11 - 12 - * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 13 - * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 14 - * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 15 - * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 16 - * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 17 - * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 18 - * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 19 - * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 20 - * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 21 - 22 - ## running spindle 23 - 24 - 1. **Set the environment variables.** For example: 25 - 26 - ```shell 27 - export SPINDLE_SERVER_HOSTNAME="your-hostname" 28 - export SPINDLE_SERVER_OWNER="your-did" 29 - ``` 30 - 31 - 2. **Build the Spindle binary.** 32 - 33 - ```shell 34 - cd core 35 - go mod download 36 - go build -o cmd/spindle/spindle cmd/spindle/main.go 37 - ``` 38 - 39 - 3. **Create the log directory.** 40 - 41 - ```shell 42 - sudo mkdir -p /var/log/spindle 43 - sudo chown $USER:$USER -R /var/log/spindle 44 - ``` 45 - 46 - 4. **Run the Spindle binary.** 47 - 48 - ```shell 49 - ./cmd/spindle/spindle 50 - ``` 51 - 52 - Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
···
-285
docs/spindle/openbao.md
··· 1 - # spindle secrets with openbao 2 - 3 - This document covers setting up Spindle to use OpenBao for secrets 4 - management via OpenBao Proxy instead of the default SQLite backend. 5 - 6 - ## overview 7 - 8 - Spindle now uses OpenBao Proxy for secrets management. The proxy handles 9 - authentication automatically using AppRole credentials, while Spindle 10 - connects to the local proxy instead of directly to the OpenBao server. 11 - 12 - This approach provides better security, automatic token renewal, and 13 - simplified application code. 14 - 15 - ## installation 16 - 17 - Install OpenBao from nixpkgs: 18 - 19 - ```bash 20 - nix shell nixpkgs#openbao # for a local server 21 - ``` 22 - 23 - ## setup 24 - 25 - The setup process can is documented for both local development and production. 26 - 27 - ### local development 28 - 29 - Start OpenBao in dev mode: 30 - 31 - ```bash 32 - bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 33 - ``` 34 - 35 - This starts OpenBao on `http://localhost:8201` with a root token. 36 - 37 - Set up environment for bao CLI: 38 - 39 - ```bash 40 - export BAO_ADDR=http://localhost:8200 41 - export BAO_TOKEN=root 42 - ``` 43 - 44 - ### production 45 - 46 - You would typically use a systemd service with a configuration file. Refer to 47 - [@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be 48 - achieved using Nix. 49 - 50 - Then, initialize the bao server: 51 - ```bash 52 - bao operator init -key-shares=1 -key-threshold=1 53 - ``` 54 - 55 - This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up: 56 - ```bash 57 - bao operator unseal <unseal_key> 58 - ``` 59 - 60 - All steps below remain the same across both dev and production setups. 61 - 62 - ### configure openbao server 63 - 64 - Create the spindle KV mount: 65 - 66 - ```bash 67 - bao secrets enable -path=spindle -version=2 kv 68 - ``` 69 - 70 - Set up AppRole authentication and policy: 71 - 72 - Create a policy file `spindle-policy.hcl`: 73 - 74 - ```hcl 75 - # Full access to spindle KV v2 data 76 - path "spindle/data/*" { 77 - capabilities = ["create", "read", "update", "delete"] 78 - } 79 - 80 - # Access to metadata for listing and management 81 - path "spindle/metadata/*" { 82 - capabilities = ["list", "read", "delete", "update"] 83 - } 84 - 85 - # Allow listing at root level 86 - path "spindle/" { 87 - capabilities = ["list"] 88 - } 89 - 90 - # Required for connection testing and health checks 91 - path "auth/token/lookup-self" { 92 - capabilities = ["read"] 93 - } 94 - ``` 95 - 96 - Apply the policy and create an AppRole: 97 - 98 - ```bash 99 - bao policy write spindle-policy spindle-policy.hcl 100 - bao auth enable approle 101 - bao write auth/approle/role/spindle \ 102 - token_policies="spindle-policy" \ 103 - token_ttl=1h \ 104 - token_max_ttl=4h \ 105 - bind_secret_id=true \ 106 - secret_id_ttl=0 \ 107 - secret_id_num_uses=0 108 - ``` 109 - 110 - Get the credentials: 111 - 112 - ```bash 113 - # Get role ID (static) 114 - ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 - 116 - # Generate secret ID 117 - SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 118 - 119 - echo "Role ID: $ROLE_ID" 120 - echo "Secret ID: $SECRET_ID" 121 - ``` 122 - 123 - ### create proxy configuration 124 - 125 - Create the credential files: 126 - 127 - ```bash 128 - # Create directory for OpenBao files 129 - mkdir -p /tmp/openbao 130 - 131 - # Save credentials 132 - echo "$ROLE_ID" > /tmp/openbao/role-id 133 - echo "$SECRET_ID" > /tmp/openbao/secret-id 134 - chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 135 - ``` 136 - 137 - Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 138 - 139 - ```hcl 140 - # OpenBao server connection 141 - vault { 142 - address = "http://localhost:8200" 143 - } 144 - 145 - # Auto-Auth using AppRole 146 - auto_auth { 147 - method "approle" { 148 - mount_path = "auth/approle" 149 - config = { 150 - role_id_file_path = "/tmp/openbao/role-id" 151 - secret_id_file_path = "/tmp/openbao/secret-id" 152 - } 153 - } 154 - 155 - # Optional: write token to file for debugging 156 - sink "file" { 157 - config = { 158 - path = "/tmp/openbao/token" 159 - mode = 0640 160 - } 161 - } 162 - } 163 - 164 - # Proxy listener for Spindle 165 - listener "tcp" { 166 - address = "127.0.0.1:8201" 167 - tls_disable = true 168 - } 169 - 170 - # Enable API proxy with auto-auth token 171 - api_proxy { 172 - use_auto_auth_token = true 173 - } 174 - 175 - # Enable response caching 176 - cache { 177 - use_auto_auth_token = true 178 - } 179 - 180 - # Logging 181 - log_level = "info" 182 - ``` 183 - 184 - ### start the proxy 185 - 186 - Start OpenBao Proxy: 187 - 188 - ```bash 189 - bao proxy -config=/tmp/openbao/proxy.hcl 190 - ``` 191 - 192 - The proxy will authenticate with OpenBao and start listening on 193 - `127.0.0.1:8201`. 194 - 195 - ### configure spindle 196 - 197 - Set these environment variables for Spindle: 198 - 199 - ```bash 200 - export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 201 - export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 202 - export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 203 - ``` 204 - 205 - Start Spindle: 206 - 207 - Spindle will now connect to the local proxy, which handles all 208 - authentication automatically. 209 - 210 - ## production setup for proxy 211 - 212 - For production, you'll want to run the proxy as a service: 213 - 214 - Place your production configuration in `/etc/openbao/proxy.hcl` with 215 - proper TLS settings for the vault connection. 216 - 217 - ## verifying setup 218 - 219 - Test the proxy directly: 220 - 221 - ```bash 222 - # Check proxy health 223 - curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 224 - 225 - # Test token lookup through proxy 226 - curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 227 - ``` 228 - 229 - Test OpenBao operations through the server: 230 - 231 - ```bash 232 - # List all secrets 233 - bao kv list spindle/ 234 - 235 - # Add a test secret via Spindle API, then check it exists 236 - bao kv list spindle/repos/ 237 - 238 - # Get a specific secret 239 - bao kv get spindle/repos/your_repo_path/SECRET_NAME 240 - ``` 241 - 242 - ## how it works 243 - 244 - - Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201) 245 - - The proxy authenticates with OpenBao using AppRole credentials 246 - - All Spindle requests go through the proxy, which injects authentication tokens 247 - - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 248 - - Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo` 249 - - The proxy handles all token renewal automatically 250 - - Spindle no longer manages tokens or authentication directly 251 - 252 - ## troubleshooting 253 - 254 - **Connection refused**: Check that the OpenBao Proxy is running and 255 - listening on the configured address. 256 - 257 - **403 errors**: Verify the AppRole credentials are correct and the policy 258 - has the necessary permissions. 259 - 260 - **404 route errors**: The spindle KV mount probably doesn't exist - run 261 - the mount creation step again. 262 - 263 - **Proxy authentication failures**: Check the proxy logs and verify the 264 - role-id and secret-id files are readable and contain valid credentials. 265 - 266 - **Secret not found after writing**: This can indicate policy permission 267 - issues. Verify the policy includes both `spindle/data/*` and 268 - `spindle/metadata/*` paths with appropriate capabilities. 269 - 270 - Check proxy logs: 271 - 272 - ```bash 273 - # If running as systemd service 274 - journalctl -u openbao-proxy -f 275 - 276 - # If running directly, check the console output 277 - ``` 278 - 279 - Test AppRole authentication manually: 280 - 281 - ```bash 282 - bao write auth/approle/login \ 283 - role_id="$(cat /tmp/openbao/role-id)" \ 284 - secret_id="$(cat /tmp/openbao/secret-id)" 285 - ```
···
-183
docs/spindle/pipeline.md
··· 1 - # spindle pipelines 2 - 3 - Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML. 4 - 5 - The fields are: 6 - 7 - - [Trigger](#trigger): A **required** field that defines when a workflow should be triggered. 8 - - [Engine](#engine): A **required** field that defines which engine a workflow should run on. 9 - - [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned. 10 - - [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need. 11 - - [Environment](#environment): An **optional** field that allows you to define environment variables. 12 - - [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow. 13 - 14 - ## Trigger 15 - 16 - The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields: 17 - 18 - - `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values: 19 - - `push`: The workflow should run every time a commit is pushed to the repository. 20 - - `pull_request`: The workflow should run every time a pull request is made or updated. 21 - - `manual`: The workflow can be triggered manually. 22 - - `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events. 23 - - `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events. 24 - 25 - For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 26 - 27 - ```yaml 28 - when: 29 - - event: ["push", "manual"] 30 - branch: ["main", "develop"] 31 - - event: ["pull_request"] 32 - branch: ["main"] 33 - ``` 34 - 35 - You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed: 36 - 37 - ```yaml 38 - when: 39 - - event: ["push"] 40 - tag: ["v*"] 41 - ``` 42 - 43 - You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches): 44 - 45 - ```yaml 46 - when: 47 - - event: ["push"] 48 - branch: ["main", "release-*"] 49 - tag: ["v*", "stable"] 50 - ``` 51 - 52 - ## Engine 53 - 54 - Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are: 55 - 56 - - `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there. 57 - 58 - Example: 59 - 60 - ```yaml 61 - engine: "nixery" 62 - ``` 63 - 64 - ## Clone options 65 - 66 - When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields: 67 - 68 - - `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default. 69 - - `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow. 70 - - `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default. 71 - 72 - The default settings are: 73 - 74 - ```yaml 75 - clone: 76 - skip: false 77 - depth: 1 78 - submodules: false 79 - ``` 80 - 81 - ## Dependencies 82 - 83 - Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch. 84 - 85 - Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so: 86 - 87 - ```yaml 88 - dependencies: 89 - # nixpkgs 90 - nixpkgs: 91 - - nodejs 92 - - go 93 - # custom registry 94 - git+https://tangled.org/@example.com/my_pkg: 95 - - my_pkg 96 - ``` 97 - 98 - Now these dependencies are available to use in your workflow! 99 - 100 - ## Environment 101 - 102 - The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 103 - 104 - Example: 105 - 106 - ```yaml 107 - environment: 108 - GOOS: "linux" 109 - GOARCH: "arm64" 110 - NODE_ENV: "production" 111 - MY_ENV_VAR: "MY_ENV_VALUE" 112 - ``` 113 - 114 - ## Steps 115 - 116 - The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields: 117 - 118 - - `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing. 119 - - `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here. 120 - - `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 121 - 122 - Example: 123 - 124 - ```yaml 125 - steps: 126 - - name: "Build backend" 127 - command: "go build" 128 - environment: 129 - GOOS: "darwin" 130 - GOARCH: "arm64" 131 - - name: "Build frontend" 132 - command: "npm run build" 133 - environment: 134 - NODE_ENV: "production" 135 - ``` 136 - 137 - ## Complete workflow 138 - 139 - ```yaml 140 - # .tangled/workflows/build.yml 141 - 142 - when: 143 - - event: ["push", "manual"] 144 - branch: ["main", "develop"] 145 - - event: ["pull_request"] 146 - branch: ["main"] 147 - 148 - engine: "nixery" 149 - 150 - # using the default values 151 - clone: 152 - skip: false 153 - depth: 1 154 - submodules: false 155 - 156 - dependencies: 157 - # nixpkgs 158 - nixpkgs: 159 - - nodejs 160 - - go 161 - # custom registry 162 - git+https://tangled.org/@example.com/my_pkg: 163 - - my_pkg 164 - 165 - environment: 166 - GOOS: "linux" 167 - GOARCH: "arm64" 168 - NODE_ENV: "production" 169 - MY_ENV_VAR: "MY_ENV_VALUE" 170 - 171 - steps: 172 - - name: "Build backend" 173 - command: "go build" 174 - environment: 175 - GOOS: "darwin" 176 - GOARCH: "arm64" 177 - - name: "Build frontend" 178 - command: "npm run build" 179 - environment: 180 - NODE_ENV: "production" 181 - ``` 182 - 183 - If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
···
+101
docs/styles.css
···
··· 1 + svg { 2 + width: 16px; 3 + height: 16px; 4 + } 5 + 6 + :root { 7 + --syntax-alert: #d20f39; 8 + --syntax-annotation: #fe640b; 9 + --syntax-attribute: #df8e1d; 10 + --syntax-basen: #40a02b; 11 + --syntax-builtin: #1e66f5; 12 + --syntax-controlflow: #8839ef; 13 + --syntax-char: #04a5e5; 14 + --syntax-constant: #fe640b; 15 + --syntax-comment: #9ca0b0; 16 + --syntax-commentvar: #7c7f93; 17 + --syntax-documentation: #9ca0b0; 18 + --syntax-datatype: #df8e1d; 19 + --syntax-decval: #40a02b; 20 + --syntax-error: #d20f39; 21 + --syntax-extension: #4c4f69; 22 + --syntax-float: #40a02b; 23 + --syntax-function: #1e66f5; 24 + --syntax-import: #40a02b; 25 + --syntax-information: #04a5e5; 26 + --syntax-keyword: #8839ef; 27 + --syntax-operator: #179299; 28 + --syntax-other: #8839ef; 29 + --syntax-preprocessor: #ea76cb; 30 + --syntax-specialchar: #04a5e5; 31 + --syntax-specialstring: #ea76cb; 32 + --syntax-string: #40a02b; 33 + --syntax-variable: #8839ef; 34 + --syntax-verbatimstring: #40a02b; 35 + --syntax-warning: #df8e1d; 36 + } 37 + 38 + @media (prefers-color-scheme: dark) { 39 + :root { 40 + --syntax-alert: #f38ba8; 41 + --syntax-annotation: #fab387; 42 + --syntax-attribute: #f9e2af; 43 + --syntax-basen: #a6e3a1; 44 + --syntax-builtin: #89b4fa; 45 + --syntax-controlflow: #cba6f7; 46 + --syntax-char: #89dceb; 47 + --syntax-constant: #fab387; 48 + --syntax-comment: #6c7086; 49 + --syntax-commentvar: #585b70; 50 + --syntax-documentation: #6c7086; 51 + --syntax-datatype: #f9e2af; 52 + --syntax-decval: #a6e3a1; 53 + --syntax-error: #f38ba8; 54 + --syntax-extension: #cdd6f4; 55 + --syntax-float: #a6e3a1; 56 + --syntax-function: #89b4fa; 57 + --syntax-import: #a6e3a1; 58 + --syntax-information: #89dceb; 59 + --syntax-keyword: #cba6f7; 60 + --syntax-operator: #94e2d5; 61 + --syntax-other: #cba6f7; 62 + --syntax-preprocessor: #f5c2e7; 63 + --syntax-specialchar: #89dceb; 64 + --syntax-specialstring: #f5c2e7; 65 + --syntax-string: #a6e3a1; 66 + --syntax-variable: #cba6f7; 67 + --syntax-verbatimstring: #a6e3a1; 68 + --syntax-warning: #f9e2af; 69 + } 70 + } 71 + 72 + /* pandoc syntax highlighting classes */ 73 + code span.al { color: var(--syntax-alert); font-weight: bold; } /* alert */ 74 + code span.an { color: var(--syntax-annotation); font-weight: bold; font-style: italic; } /* annotation */ 75 + code span.at { color: var(--syntax-attribute); } /* attribute */ 76 + code span.bn { color: var(--syntax-basen); } /* basen */ 77 + code span.bu { color: var(--syntax-builtin); } /* builtin */ 78 + code span.cf { color: var(--syntax-controlflow); font-weight: bold; } /* controlflow */ 79 + code span.ch { color: var(--syntax-char); } /* char */ 80 + code span.cn { color: var(--syntax-constant); } /* constant */ 81 + code span.co { color: var(--syntax-comment); font-style: italic; } /* comment */ 82 + code span.cv { color: var(--syntax-commentvar); font-weight: bold; font-style: italic; } /* commentvar */ 83 + code span.do { color: var(--syntax-documentation); font-style: italic; } /* documentation */ 84 + code span.dt { color: var(--syntax-datatype); } /* datatype */ 85 + code span.dv { color: var(--syntax-decval); } /* decval */ 86 + code span.er { color: var(--syntax-error); font-weight: bold; } /* error */ 87 + code span.ex { color: var(--syntax-extension); } /* extension */ 88 + code span.fl { color: var(--syntax-float); } /* float */ 89 + code span.fu { color: var(--syntax-function); } /* function */ 90 + code span.im { color: var(--syntax-import); font-weight: bold; } /* import */ 91 + code span.in { color: var(--syntax-information); font-weight: bold; font-style: italic; } /* information */ 92 + code span.kw { color: var(--syntax-keyword); font-weight: bold; } /* keyword */ 93 + code span.op { color: var(--syntax-operator); } /* operator */ 94 + code span.ot { color: var(--syntax-other); } /* other */ 95 + code span.pp { color: var(--syntax-preprocessor); } /* preprocessor */ 96 + code span.sc { color: var(--syntax-specialchar); } /* specialchar */ 97 + code span.ss { color: var(--syntax-specialstring); } /* specialstring */ 98 + code span.st { color: var(--syntax-string); } /* string */ 99 + code span.va { color: var(--syntax-variable); } /* variable */ 100 + code span.vs { color: var(--syntax-verbatimstring); } /* verbatimstring */ 101 + code span.wa { color: var(--syntax-warning); font-weight: bold; font-style: italic; } /* warning */
+160
docs/template.html
···
··· 1 + <!DOCTYPE html> 2 + <html xmlns="http://www.w3.org/1999/xhtml" lang="$lang$" xml:lang="$lang$"$if(dir)$ dir="$dir$"$endif$> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="generator" content="pandoc" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" /> 7 + $for(author-meta)$ 8 + <meta name="author" content="$author-meta$" /> 9 + $endfor$ 10 + 11 + $if(date-meta)$ 12 + <meta name="dcterms.date" content="$date-meta$" /> 13 + $endif$ 14 + 15 + $if(keywords)$ 16 + <meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$" /> 17 + $endif$ 18 + 19 + $if(description-meta)$ 20 + <meta name="description" content="$description-meta$" /> 21 + $endif$ 22 + 23 + <title>$pagetitle$</title> 24 + 25 + <style> 26 + $styles.css()$ 27 + </style> 28 + 29 + $for(css)$ 30 + <link rel="stylesheet" href="$css$" /> 31 + $endfor$ 32 + 33 + $for(header-includes)$ 34 + $header-includes$ 35 + $endfor$ 36 + 37 + <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 38 + <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/> 39 + <link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> 40 + <link rel="apple-touch-icon" href="/static/logos/dolly.png"/> 41 + 42 + </head> 43 + <body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh"> 44 + $for(include-before)$ 45 + $include-before$ 46 + $endfor$ 47 + 48 + $if(toc)$ 49 + <!-- mobile TOC trigger --> 50 + <div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700"> 51 + <button 52 + type="button" 53 + popovertarget="mobile-toc-popover" 54 + popovertargetaction="toggle" 55 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white" 56 + > 57 + ${ menu.svg() } 58 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 59 + </button> 60 + </div> 61 + 62 + <div 63 + id="mobile-toc-popover" 64 + popover 65 + class="mobile-toc-popover 66 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 67 + h-full overflow-y-auto shadow-sm 68 + px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0" 69 + > 70 + <div class="flex flex-col min-h-full"> 71 + <div class="flex-1 space-y-4"> 72 + <button 73 + type="button" 74 + popovertarget="mobile-toc-popover" 75 + popovertargetaction="toggle" 76 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4"> 77 + ${ x.svg() } 78 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 79 + </button> 80 + ${ logo.html() } 81 + ${ search.html() } 82 + ${ table-of-contents:toc.html() } 83 + </div> 84 + ${ single-page:mode.html() } 85 + </div> 86 + </div> 87 + 88 + <!-- desktop sidebar toc --> 89 + <nav 90 + id="$idprefix$TOC" 91 + role="doc-toc" 92 + class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen 93 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 94 + p-4 z-50 overflow-y-auto"> 95 + ${ logo.html() } 96 + ${ search.html() } 97 + <div class="flex-1"> 98 + $if(toc-title)$ 99 + <h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2> 100 + $endif$ 101 + ${ table-of-contents:toc.html() } 102 + </div> 103 + ${ single-page:mode.html() } 104 + </nav> 105 + $endif$ 106 + 107 + <div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col"> 108 + <main class="max-w-4xl w-full mx-auto p-6 flex-1"> 109 + $if(top)$ 110 + $-- only print title block if this is NOT the top page 111 + $else$ 112 + $if(title)$ 113 + <header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700"> 114 + <h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1> 115 + $if(subtitle)$ 116 + <p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p> 117 + $endif$ 118 + $for(author)$ 119 + <p class="text-sm text-gray-500 dark:text-gray-400">$author$</p> 120 + $endfor$ 121 + $if(date)$ 122 + <p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p> 123 + $endif$ 124 + $endif$ 125 + </header> 126 + $if(abstract)$ 127 + <article class="prose dark:prose-invert max-w-none"> 128 + $abstract$ 129 + </article> 130 + $endif$ 131 + $endif$ 132 + 133 + <article class="prose dark:prose-invert max-w-none"> 134 + $body$ 135 + </article> 136 + </main> 137 + <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> 138 + <div class="max-w-4xl mx-auto px-8 py-4"> 139 + <div class="flex justify-between gap-4"> 140 + <span class="flex-1"> 141 + $if(previous.url)$ 142 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Previous</span> 143 + <a href="$previous.url$" accesskey="p" rel="previous">$previous.title$</a> 144 + $endif$ 145 + </span> 146 + <span class="flex-1 text-right"> 147 + $if(next.url)$ 148 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Next</span> 149 + <a href="$next.url$" accesskey="n" rel="next">$next.title$</a> 150 + $endif$ 151 + </span> 152 + </div> 153 + </div> 154 + </nav> 155 + </div> 156 + $for(include-after)$ 157 + $include-after$ 158 + $endfor$ 159 + </body> 160 + </html>
+4
docs/toc.html
···
··· 1 + <div class="[&_ul]:space-y-6 [&_ul]:pl-0 [&_ul]:font-bold [&_ul_ul]:pl-4 [&_ul_ul]:font-normal [&_ul_ul]:space-y-2 [&_li]:space-y-2"> 2 + $table-of-contents$ 3 + </div> 4 +
+3 -3
flake.lock
··· 150 }, 151 "nixpkgs": { 152 "locked": { 153 - "lastModified": 1765186076, 154 - "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", 155 "owner": "nixos", 156 "repo": "nixpkgs", 157 - "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", 158 "type": "github" 159 }, 160 "original": {
··· 150 }, 151 "nixpkgs": { 152 "locked": { 153 + "lastModified": 1766070988, 154 + "narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=", 155 "owner": "nixos", 156 "repo": "nixpkgs", 157 + "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", 158 "type": "github" 159 }, 160 "original": {
+21 -3
flake.nix
··· 76 }; 77 buildGoApplication = 78 (self.callPackage "${gomod2nix}/builder" { 79 - gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; 80 }).buildGoApplication; 81 modules = ./nix/gomod2nix.toml; 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { ··· 88 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 89 }; 90 appview = self.callPackage ./nix/pkgs/appview.nix {}; 91 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 92 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 93 knot = self.callPackage ./nix/pkgs/knot.nix {}; 94 }); 95 in { 96 overlays.default = final: prev: { 97 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 98 }; 99 100 packages = forAllSystems (system: let ··· 103 staticPackages = mkPackageSet pkgs.pkgsStatic; 104 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 105 in { 106 - inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 107 108 pkgsStatic-appview = staticPackages.appview; 109 pkgsStatic-knot = staticPackages.knot; 110 pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped; 111 pkgsStatic-spindle = staticPackages.spindle; 112 pkgsStatic-sqlite-lib = staticPackages.sqlite-lib; 113 114 pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview; 115 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 116 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 117 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 118 119 treefmt-wrapper = pkgs.treefmt.withConfig { 120 settings.formatter = {
··· 76 }; 77 buildGoApplication = 78 (self.callPackage "${gomod2nix}/builder" { 79 + gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix; 80 }).buildGoApplication; 81 modules = ./nix/gomod2nix.toml; 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { ··· 88 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 89 }; 90 appview = self.callPackage ./nix/pkgs/appview.nix {}; 91 + docs = self.callPackage ./nix/pkgs/docs.nix { 92 + inherit inter-fonts-src ibm-plex-mono-src lucide-src; 93 + }; 94 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 95 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 96 knot = self.callPackage ./nix/pkgs/knot.nix {}; 97 + dolly = self.callPackage ./nix/pkgs/dolly.nix {}; 98 }); 99 in { 100 overlays.default = final: prev: { 101 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly; 102 }; 103 104 packages = forAllSystems (system: let ··· 107 staticPackages = mkPackageSet pkgs.pkgsStatic; 108 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 109 in { 110 + inherit 111 + (packages) 112 + appview 113 + appview-static-files 114 + lexgen 115 + goat 116 + spindle 117 + knot 118 + knot-unwrapped 119 + sqlite-lib 120 + docs 121 + dolly 122 + ; 123 124 pkgsStatic-appview = staticPackages.appview; 125 pkgsStatic-knot = staticPackages.knot; 126 pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped; 127 pkgsStatic-spindle = staticPackages.spindle; 128 pkgsStatic-sqlite-lib = staticPackages.sqlite-lib; 129 + pkgsStatic-dolly = staticPackages.dolly; 130 131 pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview; 132 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 133 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 134 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 135 + pkgsCross-gnu64-pkgsStatic-dolly = crossPackages.dolly; 136 137 treefmt-wrapper = pkgs.treefmt.withConfig { 138 settings.formatter = {
+88
ico/ico.go
···
··· 1 + package ico 2 + 3 + import ( 4 + "bytes" 5 + "encoding/binary" 6 + "fmt" 7 + "image" 8 + "image/png" 9 + ) 10 + 11 + type IconDir struct { 12 + Reserved uint16 // must be 0 13 + Type uint16 // 1 for ICO, 2 for CUR 14 + Count uint16 // number of images 15 + } 16 + 17 + type IconDirEntry struct { 18 + Width uint8 // 0 means 256 19 + Height uint8 // 0 means 256 20 + ColorCount uint8 21 + Reserved uint8 // must be 0 22 + ColorPlanes uint16 // 0 or 1 23 + BitsPerPixel uint16 24 + SizeInBytes uint32 25 + Offset uint32 26 + } 27 + 28 + func ImageToIco(img image.Image) ([]byte, error) { 29 + // encode image as png 30 + var pngBuf bytes.Buffer 31 + if err := png.Encode(&pngBuf, img); err != nil { 32 + return nil, fmt.Errorf("failed to encode PNG: %w", err) 33 + } 34 + pngData := pngBuf.Bytes() 35 + 36 + // get image dimensions 37 + bounds := img.Bounds() 38 + width := bounds.Dx() 39 + height := bounds.Dy() 40 + 41 + // prepare output buffer 42 + var icoBuf bytes.Buffer 43 + 44 + iconDir := IconDir{ 45 + Reserved: 0, 46 + Type: 1, // ICO format 47 + Count: 1, // One image 48 + } 49 + 50 + w := uint8(width) 51 + h := uint8(height) 52 + 53 + // width/height of 256 should be stored as 0 54 + if width == 256 { 55 + w = 0 56 + } 57 + if height == 256 { 58 + h = 0 59 + } 60 + 61 + iconDirEntry := IconDirEntry{ 62 + Width: w, 63 + Height: h, 64 + ColorCount: 0, // 0 for PNG (32-bit) 65 + Reserved: 0, 66 + ColorPlanes: 1, 67 + BitsPerPixel: 32, // PNG with alpha 68 + SizeInBytes: uint32(len(pngData)), 69 + Offset: 6 + 16, // Size of ICONDIR + ICONDIRENTRY 70 + } 71 + 72 + // write IconDir 73 + if err := binary.Write(&icoBuf, binary.LittleEndian, iconDir); err != nil { 74 + return nil, fmt.Errorf("failed to write ICONDIR: %w", err) 75 + } 76 + 77 + // write IconDirEntry 78 + if err := binary.Write(&icoBuf, binary.LittleEndian, iconDirEntry); err != nil { 79 + return nil, fmt.Errorf("failed to write ICONDIRENTRY: %w", err) 80 + } 81 + 82 + // write PNG data directly 83 + if _, err := icoBuf.Write(pngData); err != nil { 84 + return nil, fmt.Errorf("failed to write PNG data: %w", err) 85 + } 86 + 87 + return icoBuf.Bytes(), nil 88 + }
+19 -1
input.css
··· 124 dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700; 125 } 126 127 .btn-create { 128 @apply btn text-white 129 before:bg-green-600 hover:before:bg-green-700 ··· 131 before:border before:border-green-700 hover:before:border-green-800 132 focus-visible:before:outline-green-500 133 disabled:before:bg-green-400 dark:disabled:before:bg-green-600; 134 } 135 136 .prose hr { ··· 162 } 163 164 .prose a.mention { 165 - @apply no-underline hover:underline; 166 } 167 168 .prose li { ··· 255 @apply py-1 text-gray-900 dark:text-gray-100; 256 } 257 } 258 } 259 260 /* Background */
··· 124 dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700; 125 } 126 127 + .btn-flat { 128 + @apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center justify-center 129 + bg-transparent px-2 pb-[0.2rem] text-sm text-gray-900 130 + before:absolute before:inset-0 before:-z-10 before:block before:rounded 131 + before:border before:border-gray-200 before:bg-white 132 + before:content-[''] before:transition-all before:duration-150 before:ease-in-out 133 + hover:before:bg-gray-50 134 + dark:hover:before:bg-gray-700 135 + focus:outline-none focus-visible:before:outline focus-visible:before:outline-2 focus-visible:before:outline-gray-400 136 + disabled:cursor-not-allowed disabled:opacity-50 137 + dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700; 138 + } 139 + 140 .btn-create { 141 @apply btn text-white 142 before:bg-green-600 hover:before:bg-green-700 ··· 144 before:border before:border-green-700 hover:before:border-green-800 145 focus-visible:before:outline-green-500 146 disabled:before:bg-green-400 dark:disabled:before:bg-green-600; 147 + } 148 + 149 + .prose { 150 + overflow-wrap: anywhere; 151 } 152 153 .prose hr { ··· 179 } 180 181 .prose a.mention { 182 + @apply no-underline hover:underline font-bold; 183 } 184 185 .prose li { ··· 272 @apply py-1 text-gray-900 dark:text-gray-100; 273 } 274 } 275 + 276 } 277 278 /* Background */
+3 -8
knotserver/git/diff.go
··· 64 65 for _, tf := range d.TextFragments { 66 ndiff.TextFragments = append(ndiff.TextFragments, *tf) 67 - for _, l := range tf.Lines { 68 - switch l.Op { 69 - case gitdiff.OpAdd: 70 - nd.Stat.Insertions += 1 71 - case gitdiff.OpDelete: 72 - nd.Stat.Deletions += 1 73 - } 74 - } 75 } 76 77 nd.Diff = append(nd.Diff, ndiff) 78 } 79 80 nd.Commit.FromGoGitCommit(c) 81 82 return &nd, nil
··· 64 65 for _, tf := range d.TextFragments { 66 ndiff.TextFragments = append(ndiff.TextFragments, *tf) 67 + nd.Stat.Insertions += tf.LinesAdded 68 + nd.Stat.Deletions += tf.LinesDeleted 69 } 70 71 nd.Diff = append(nd.Diff, ndiff) 72 } 73 74 + nd.Stat.FilesChanged += len(diffs) 75 nd.Commit.FromGoGitCommit(c) 76 77 return &nd, nil
+33
lexicons/pipeline/cancelPipeline.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.pipeline.cancelPipeline", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Cancel a running pipeline", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["repo", "pipeline", "workflow"], 13 + "properties": { 14 + "repo": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "repo at-uri, spindle can't resolve repo from pipeline at-uri yet" 18 + }, 19 + "pipeline": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "pipeline at-uri" 23 + }, 24 + "workflow": { 25 + "type": "string", 26 + "description": "workflow name" 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+10 -2
lexicons/pulls/pull.json
··· 12 "required": [ 13 "target", 14 "title", 15 - "patch", 16 "createdAt" 17 ], 18 "properties": { ··· 27 "type": "string" 28 }, 29 "patch": { 30 - "type": "string" 31 }, 32 "source": { 33 "type": "ref",
··· 12 "required": [ 13 "target", 14 "title", 15 + "patchBlob", 16 "createdAt" 17 ], 18 "properties": { ··· 27 "type": "string" 28 }, 29 "patch": { 30 + "type": "string", 31 + "description": "(deprecated) use patchBlob instead" 32 + }, 33 + "patchBlob": { 34 + "type": "blob", 35 + "accept": [ 36 + "text/x-patch" 37 + ], 38 + "description": "patch content" 39 }, 40 "source": { 41 "type": "ref",
+3
nix/modules/appview.nix
··· 1 { 2 config, 3 lib, 4 ... ··· 259 after = ["redis-appview.service" "network-online.target"]; 260 requires = ["redis-appview.service"]; 261 wants = ["network-online.target"]; 262 263 serviceConfig = { 264 Type = "simple";
··· 1 { 2 + pkgs, 3 config, 4 lib, 5 ... ··· 260 after = ["redis-appview.service" "network-online.target"]; 261 requires = ["redis-appview.service"]; 262 wants = ["network-online.target"]; 263 + 264 + path = [pkgs.diffutils]; 265 266 serviceConfig = { 267 Type = "simple";
+6 -1
nix/pkgs/appview-static-files.nix
··· 8 actor-typeahead-src, 9 sqlite-lib, 10 tailwindcss, 11 src, 12 }: 13 runCommandLocal "appview-static-files" { ··· 17 (allow file-read* (subpath "/System/Library/OpenSSL")) 18 ''; 19 } '' 20 - mkdir -p $out/{fonts,icons} && cd $out 21 cp -f ${htmx-src} htmx.min.js 22 cp -f ${htmx-ws-src} htmx-ext-ws.min.js 23 cp -rf ${lucide-src}/*.svg icons/ ··· 26 cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 27 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 28 cp -f ${actor-typeahead-src}/actor-typeahead.js . 29 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 30 # for whatever reason (produces broken css), so we are doing this instead 31 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
··· 8 actor-typeahead-src, 9 sqlite-lib, 10 tailwindcss, 11 + dolly, 12 src, 13 }: 14 runCommandLocal "appview-static-files" { ··· 18 (allow file-read* (subpath "/System/Library/OpenSSL")) 19 ''; 20 } '' 21 + mkdir -p $out/{fonts,icons,logos} && cd $out 22 cp -f ${htmx-src} htmx.min.js 23 cp -f ${htmx-ws-src} htmx-ext-ws.min.js 24 cp -rf ${lucide-src}/*.svg icons/ ··· 27 cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 28 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 29 cp -f ${actor-typeahead-src}/actor-typeahead.js . 30 + 31 + ${dolly}/bin/dolly -output logos/dolly.png -size 180x180 32 + ${dolly}/bin/dolly -output logos/dolly.ico -size 48x48 33 + ${dolly}/bin/dolly -output logos/dolly.svg -color currentColor 34 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 35 # for whatever reason (produces broken css), so we are doing this instead 36 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+62
nix/pkgs/docs.nix
···
··· 1 + { 2 + pandoc, 3 + tailwindcss, 4 + runCommandLocal, 5 + inter-fonts-src, 6 + ibm-plex-mono-src, 7 + lucide-src, 8 + dolly, 9 + src, 10 + }: 11 + runCommandLocal "docs" {} '' 12 + mkdir -p working 13 + 14 + # copy templates, themes, styles, filters to working directory 15 + cp ${src}/docs/*.html working/ 16 + cp ${src}/docs/*.theme working/ 17 + cp ${src}/docs/*.css working/ 18 + 19 + # icons 20 + cp -rf ${lucide-src}/*.svg working/ 21 + 22 + # logo 23 + ${dolly}/bin/dolly -output working/dolly.svg -color currentColor 24 + 25 + # content - chunked 26 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 27 + -o $out/ \ 28 + -t chunkedhtml \ 29 + --variable toc \ 30 + --variable-json single-page=false \ 31 + --toc-depth=2 \ 32 + --css=stylesheet.css \ 33 + --chunk-template="%i.html" \ 34 + --highlight-style=working/highlight.theme \ 35 + --template=working/template.html 36 + 37 + # content - single page 38 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 39 + -o $out/single-page.html \ 40 + --toc \ 41 + --variable toc \ 42 + --variable single-page \ 43 + --toc-depth=2 \ 44 + --css=stylesheet.css \ 45 + --highlight-style=working/highlight.theme \ 46 + --template=working/template.html 47 + 48 + # fonts 49 + mkdir -p $out/static/fonts 50 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 $out/static/fonts/ 51 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 $out/static/fonts/ 52 + cp -f ${inter-fonts-src}/InterVariable*.ttf $out/static/fonts/ 53 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 $out/static/fonts/ 54 + 55 + # favicons 56 + ${dolly}/bin/dolly -output $out/static/logos/dolly.png -size 180x180 57 + ${dolly}/bin/dolly -output $out/static/logos/dolly.ico -size 48x48 58 + ${dolly}/bin/dolly -output $out/static/logos/dolly.svg -color currentColor 59 + 60 + # styles 61 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/stylesheet.css 62 + ''
+21
nix/pkgs/dolly.nix
···
··· 1 + { 2 + buildGoApplication, 3 + modules, 4 + src, 5 + }: 6 + buildGoApplication { 7 + pname = "dolly"; 8 + version = "0.1.0"; 9 + inherit src modules; 10 + 11 + # patch the static dir 12 + postUnpack = '' 13 + pushd source 14 + mkdir -p appview/pages/static 15 + touch appview/pages/static/x 16 + popd 17 + ''; 18 + 19 + doCheck = false; 20 + subPackages = ["cmd/dolly"]; 21 + }
+1 -1
nix/vm.nix
··· 8 var = builtins.getEnv name; 9 in 10 if var == "" 11 - then throw "\$${name} must be defined, see docs/hacking.md for more details" 12 else var; 13 envVarOr = name: default: let 14 var = builtins.getEnv name;
··· 8 var = builtins.getEnv name; 9 in 10 if var == "" 11 + then throw "\$${name} must be defined, see https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled for more details" 12 else var; 13 envVarOr = name: default: let 14 var = builtins.getEnv name;
+66 -10
patchutil/interdiff.go
··· 5 "strings" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 "tangled.org/core/types" 9 ) 10 ··· 12 Files []*InterdiffFile 13 } 14 15 - func (i *InterdiffResult) AffectedFiles() []string { 16 - files := make([]string, len(i.Files)) 17 - for _, f := range i.Files { 18 - files = append(files, f.Name) 19 } 20 - return files 21 } 22 23 func (i *InterdiffResult) String() string { ··· 36 Status InterdiffFileStatus 37 } 38 39 - func (s *InterdiffFile) Split() *types.SplitDiff { 40 fragments := make([]types.SplitFragment, len(s.TextFragments)) 41 42 for i, fragment := range s.TextFragments { ··· 49 } 50 } 51 52 - return &types.SplitDiff{ 53 Name: s.Id(), 54 TextFragments: fragments, 55 } 56 } 57 58 - // used by html elements as a unique ID for hrefs 59 - func (s *InterdiffFile) Id() string { 60 - return s.Name 61 } 62 63 func (s *InterdiffFile) String() string {
··· 5 "strings" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.org/core/appview/filetree" 9 "tangled.org/core/types" 10 ) 11 ··· 13 Files []*InterdiffFile 14 } 15 16 + func (i *InterdiffResult) Stats() types.DiffStat { 17 + var ins, del int64 18 + for _, s := range i.ChangedFiles() { 19 + stat := s.Stats() 20 + ins += stat.Insertions 21 + del += stat.Deletions 22 + } 23 + return types.DiffStat{ 24 + Insertions: ins, 25 + Deletions: del, 26 + FilesChanged: len(i.Files), 27 } 28 + } 29 + 30 + func (i *InterdiffResult) ChangedFiles() []types.DiffFileRenderer { 31 + drs := make([]types.DiffFileRenderer, len(i.Files)) 32 + for i, s := range i.Files { 33 + drs[i] = s 34 + } 35 + return drs 36 + } 37 + 38 + func (i *InterdiffResult) FileTree() *filetree.FileTreeNode { 39 + fs := make([]string, len(i.Files)) 40 + for i, s := range i.Files { 41 + fs[i] = s.Name 42 + } 43 + return filetree.FileTree(fs) 44 } 45 46 func (i *InterdiffResult) String() string { ··· 59 Status InterdiffFileStatus 60 } 61 62 + func (s *InterdiffFile) Id() string { 63 + return s.Name 64 + } 65 + 66 + func (s *InterdiffFile) Split() types.SplitDiff { 67 fragments := make([]types.SplitFragment, len(s.TextFragments)) 68 69 for i, fragment := range s.TextFragments { ··· 76 } 77 } 78 79 + return types.SplitDiff{ 80 Name: s.Id(), 81 TextFragments: fragments, 82 } 83 } 84 85 + func (s *InterdiffFile) CanRender() string { 86 + if s.Status.IsUnchanged() { 87 + return "This file has not been changed." 88 + } else if s.Status.IsRebased() { 89 + return "This patch was likely rebased, as context lines do not match." 90 + } else if s.Status.IsError() { 91 + return "Failed to calculate interdiff for this file." 92 + } else { 93 + return "" 94 + } 95 + } 96 + 97 + func (s *InterdiffFile) Names() types.DiffFileName { 98 + var n types.DiffFileName 99 + n.New = s.Name 100 + return n 101 + } 102 + 103 + func (s *InterdiffFile) Stats() types.DiffFileStat { 104 + var ins, del int64 105 + 106 + if s.File != nil { 107 + for _, f := range s.TextFragments { 108 + ins += f.LinesAdded 109 + del += f.LinesDeleted 110 + } 111 + } 112 + 113 + return types.DiffFileStat{ 114 + Insertions: ins, 115 + Deletions: del, 116 + } 117 } 118 119 func (s *InterdiffFile) String() string {
+9
patchutil/patchutil_test.go
··· 4 "errors" 5 "reflect" 6 "testing" 7 ) 8 9 func TestIsPatchValid(t *testing.T) { ··· 323 }) 324 } 325 }
··· 4 "errors" 5 "reflect" 6 "testing" 7 + 8 + "tangled.org/core/types" 9 ) 10 11 func TestIsPatchValid(t *testing.T) { ··· 325 }) 326 } 327 } 328 + 329 + func TestImplsInterfaces(t *testing.T) { 330 + id := &InterdiffResult{} 331 + _ = isDiffsRenderer(id) 332 + } 333 + 334 + func isDiffsRenderer[S types.DiffRenderer](S) bool { return true }
+3 -3
readme.md
··· 10 11 ## docs 12 13 - * [knot hosting guide](/docs/knot-hosting.md) 14 - * [contributing guide](/docs/contributing.md) **please read before opening a PR!** 15 - * [hacking on tangled](/docs/hacking.md) 16 17 ## security 18
··· 10 11 ## docs 12 13 + - [knot hosting guide](https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide) 14 + - [contributing guide](https://docs.tangled.org/contribution-guide.html#contribution-guide) **please read before opening a PR!** 15 + - [hacking on tangled](https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled) 16 17 ## security 18
+6 -18
spindle/db/events.go
··· 18 EventJson string `json:"event"` 19 } 20 21 - func (d *DB) InsertEvent(event Event, notifier *notifier.Notifier) error { 22 _, err := d.Exec( 23 `insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`, 24 event.Rkey, ··· 70 return evts, nil 71 } 72 73 - func (d *DB) CreateStatusEvent(rkey string, s tangled.PipelineStatus, n *notifier.Notifier) error { 74 - eventJson, err := json.Marshal(s) 75 - if err != nil { 76 - return err 77 - } 78 - 79 - event := Event{ 80 - Rkey: rkey, 81 - Nsid: tangled.PipelineStatusNSID, 82 - Created: time.Now().UnixNano(), 83 - EventJson: string(eventJson), 84 - } 85 - 86 - return d.InsertEvent(event, n) 87 - } 88 - 89 func (d *DB) createStatusEvent( 90 workflowId models.WorkflowId, 91 statusKind models.StatusKind, ··· 116 EventJson: string(eventJson), 117 } 118 119 - return d.InsertEvent(event, n) 120 121 } 122 ··· 164 165 func (d *DB) StatusFailed(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error { 166 return d.createStatusEvent(workflowId, models.StatusKindFailed, &workflowError, &exitCode, n) 167 } 168 169 func (d *DB) StatusSuccess(workflowId models.WorkflowId, n *notifier.Notifier) error {
··· 18 EventJson string `json:"event"` 19 } 20 21 + func (d *DB) insertEvent(event Event, notifier *notifier.Notifier) error { 22 _, err := d.Exec( 23 `insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`, 24 event.Rkey, ··· 70 return evts, nil 71 } 72 73 func (d *DB) createStatusEvent( 74 workflowId models.WorkflowId, 75 statusKind models.StatusKind, ··· 100 EventJson: string(eventJson), 101 } 102 103 + return d.insertEvent(event, n) 104 105 } 106 ··· 148 149 func (d *DB) StatusFailed(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error { 150 return d.createStatusEvent(workflowId, models.StatusKindFailed, &workflowError, &exitCode, n) 151 + } 152 + 153 + func (d *DB) StatusCancelled(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error { 154 + return d.createStatusEvent(workflowId, models.StatusKindCancelled, &workflowError, &exitCode, n) 155 } 156 157 func (d *DB) StatusSuccess(workflowId models.WorkflowId, n *notifier.Notifier) error {
+10 -3
spindle/engine/engine.go
··· 36 l.Info("using workflow timeout", "timeout", workflowTimeout) 37 38 for _, w := range wfs { 39 - wg.Go(func() { 40 wid := models.WorkflowId{ 41 PipelineId: pipelineId, 42 Name: w.Name, ··· 67 } 68 defer eng.DestroyWorkflow(ctx, wid) 69 70 - wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 71 if err != nil { 72 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 wfLogger = nil ··· 115 if err != nil { 116 l.Error("failed to set workflow status to success", "wid", wid, "err", err) 117 } 118 - }) 119 } 120 } 121
··· 36 l.Info("using workflow timeout", "timeout", workflowTimeout) 37 38 for _, w := range wfs { 39 + wg.Add(1) 40 + go func() { 41 + defer wg.Done() 42 + 43 wid := models.WorkflowId{ 44 PipelineId: pipelineId, 45 Name: w.Name, ··· 70 } 71 defer eng.DestroyWorkflow(ctx, wid) 72 73 + secretValues := make([]string, len(allSecrets)) 74 + for i, s := range allSecrets { 75 + secretValues[i] = s.Value 76 + } 77 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 78 if err != nil { 79 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 80 wfLogger = nil ··· 122 if err != nil { 123 l.Error("failed to set workflow status to success", "wid", wid, "err", err) 124 } 125 + }() 126 } 127 } 128
+24 -13
spindle/engines/nixery/engine.go
··· 179 return err 180 } 181 e.registerCleanup(wid, func(ctx context.Context) error { 182 - return e.docker.NetworkRemove(ctx, networkName(wid)) 183 }) 184 185 addl := wf.Data.(addlFields) ··· 229 return fmt.Errorf("creating container: %w", err) 230 } 231 e.registerCleanup(wid, func(ctx context.Context) error { 232 - err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 233 - if err != nil { 234 - return err 235 } 236 237 - return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 238 RemoveVolumes: true, 239 RemoveLinks: false, 240 Force: false, 241 }) 242 }) 243 244 - err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 245 - if err != nil { 246 return fmt.Errorf("starting container: %w", err) 247 } 248 ··· 394 } 395 396 func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 397 - e.cleanupMu.Lock() 398 - key := wid.String() 399 - 400 - fns := e.cleanup[key] 401 - delete(e.cleanup, key) 402 - e.cleanupMu.Unlock() 403 404 for _, fn := range fns { 405 if err := fn(ctx); err != nil { ··· 415 416 key := wid.String() 417 e.cleanup[key] = append(e.cleanup[key], fn) 418 } 419 420 func networkName(wid models.WorkflowId) string {
··· 179 return err 180 } 181 e.registerCleanup(wid, func(ctx context.Context) error { 182 + if err := e.docker.NetworkRemove(ctx, networkName(wid)); err != nil { 183 + return fmt.Errorf("removing network: %w", err) 184 + } 185 + return nil 186 }) 187 188 addl := wf.Data.(addlFields) ··· 232 return fmt.Errorf("creating container: %w", err) 233 } 234 e.registerCleanup(wid, func(ctx context.Context) error { 235 + if err := e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}); err != nil { 236 + return fmt.Errorf("stopping container: %w", err) 237 } 238 239 + err := e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 240 RemoveVolumes: true, 241 RemoveLinks: false, 242 Force: false, 243 }) 244 + if err != nil { 245 + return fmt.Errorf("removing container: %w", err) 246 + } 247 + return nil 248 }) 249 250 + if err := e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { 251 return fmt.Errorf("starting container: %w", err) 252 } 253 ··· 399 } 400 401 func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 402 + fns := e.drainCleanups(wid) 403 404 for _, fn := range fns { 405 if err := fn(ctx); err != nil { ··· 415 416 key := wid.String() 417 e.cleanup[key] = append(e.cleanup[key], fn) 418 + } 419 + 420 + func (e *Engine) drainCleanups(wid models.WorkflowId) []cleanupFunc { 421 + e.cleanupMu.Lock() 422 + key := wid.String() 423 + 424 + fns := e.cleanup[key] 425 + delete(e.cleanup, key) 426 + e.cleanupMu.Unlock() 427 + 428 + return fns 429 } 430 431 func networkName(wid models.WorkflowId) string {
+6 -1
spindle/models/logger.go
··· 12 type WorkflowLogger struct { 13 file *os.File 14 encoder *json.Encoder 15 } 16 17 - func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 path := LogFilePath(baseDir, wid) 19 20 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 25 return &WorkflowLogger{ 26 file: file, 27 encoder: json.NewEncoder(file), 28 }, nil 29 } 30 ··· 62 63 func (w *dataWriter) Write(p []byte) (int, error) { 64 line := strings.TrimRight(string(p), "\r\n") 65 entry := NewDataLogLine(w.idx, line, w.stream) 66 if err := w.logger.encoder.Encode(entry); err != nil { 67 return 0, err
··· 12 type WorkflowLogger struct { 13 file *os.File 14 encoder *json.Encoder 15 + mask *SecretMask 16 } 17 18 + func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) { 19 path := LogFilePath(baseDir, wid) 20 21 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 26 return &WorkflowLogger{ 27 file: file, 28 encoder: json.NewEncoder(file), 29 + mask: NewSecretMask(secretValues), 30 }, nil 31 } 32 ··· 64 65 func (w *dataWriter) Write(p []byte) (int, error) { 66 line := strings.TrimRight(string(p), "\r\n") 67 + if w.logger.mask != nil { 68 + line = w.logger.mask.Mask(line) 69 + } 70 entry := NewDataLogLine(w.idx, line, w.stream) 71 if err := w.logger.encoder.Encode(entry); err != nil { 72 return 0, err
+2 -2
spindle/models/models.go
··· 53 StatusKindRunning, 54 } 55 FinishStates [4]StatusKind = [4]StatusKind{ 56 - StatusKindCancelled, 57 StatusKindFailed, 58 - StatusKindSuccess, 59 StatusKindTimeout, 60 } 61 ) 62
··· 53 StatusKindRunning, 54 } 55 FinishStates [4]StatusKind = [4]StatusKind{ 56 StatusKindFailed, 57 StatusKindTimeout, 58 + StatusKindCancelled, 59 + StatusKindSuccess, 60 } 61 ) 62
+1 -1
spindle/models/pipeline_env.go
··· 20 // Standard CI environment variable 21 env["CI"] = "true" 22 23 - env["TANGLED_PIPELINE_ID"] = pipelineId.Rkey 24 25 // Repo info 26 if tr.Repo != nil {
··· 20 // Standard CI environment variable 21 env["CI"] = "true" 22 23 + env["TANGLED_PIPELINE_ID"] = pipelineId.AtUri().String() 24 25 // Repo info 26 if tr.Repo != nil {
+51
spindle/models/secret_mask.go
···
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "strings" 6 + ) 7 + 8 + // SecretMask replaces secret values in strings with "***". 9 + type SecretMask struct { 10 + replacer *strings.Replacer 11 + } 12 + 13 + // NewSecretMask creates a mask for the given secret values. 14 + // Also registers base64-encoded variants of each secret. 15 + func NewSecretMask(values []string) *SecretMask { 16 + var pairs []string 17 + 18 + for _, value := range values { 19 + if value == "" { 20 + continue 21 + } 22 + 23 + pairs = append(pairs, value, "***") 24 + 25 + b64 := base64.StdEncoding.EncodeToString([]byte(value)) 26 + if b64 != value { 27 + pairs = append(pairs, b64, "***") 28 + } 29 + 30 + b64NoPad := strings.TrimRight(b64, "=") 31 + if b64NoPad != b64 && b64NoPad != value { 32 + pairs = append(pairs, b64NoPad, "***") 33 + } 34 + } 35 + 36 + if len(pairs) == 0 { 37 + return nil 38 + } 39 + 40 + return &SecretMask{ 41 + replacer: strings.NewReplacer(pairs...), 42 + } 43 + } 44 + 45 + // Mask replaces all registered secret values with "***". 46 + func (m *SecretMask) Mask(input string) string { 47 + if m == nil || m.replacer == nil { 48 + return input 49 + } 50 + return m.replacer.Replace(input) 51 + }
+135
spindle/models/secret_mask_test.go
···
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "testing" 6 + ) 7 + 8 + func TestSecretMask_BasicMasking(t *testing.T) { 9 + mask := NewSecretMask([]string{"mysecret123"}) 10 + 11 + input := "The password is mysecret123 in this log" 12 + expected := "The password is *** in this log" 13 + 14 + result := mask.Mask(input) 15 + if result != expected { 16 + t.Errorf("expected %q, got %q", expected, result) 17 + } 18 + } 19 + 20 + func TestSecretMask_Base64Encoded(t *testing.T) { 21 + secret := "mysecret123" 22 + mask := NewSecretMask([]string{secret}) 23 + 24 + b64 := base64.StdEncoding.EncodeToString([]byte(secret)) 25 + input := "Encoded: " + b64 26 + expected := "Encoded: ***" 27 + 28 + result := mask.Mask(input) 29 + if result != expected { 30 + t.Errorf("expected %q, got %q", expected, result) 31 + } 32 + } 33 + 34 + func TestSecretMask_Base64NoPadding(t *testing.T) { 35 + // "test" encodes to "dGVzdA==" with padding 36 + secret := "test" 37 + mask := NewSecretMask([]string{secret}) 38 + 39 + b64NoPad := "dGVzdA" // base64 without padding 40 + input := "Token: " + b64NoPad 41 + expected := "Token: ***" 42 + 43 + result := mask.Mask(input) 44 + if result != expected { 45 + t.Errorf("expected %q, got %q", expected, result) 46 + } 47 + } 48 + 49 + func TestSecretMask_MultipleSecrets(t *testing.T) { 50 + mask := NewSecretMask([]string{"password1", "apikey123"}) 51 + 52 + input := "Using password1 and apikey123 for auth" 53 + expected := "Using *** and *** for auth" 54 + 55 + result := mask.Mask(input) 56 + if result != expected { 57 + t.Errorf("expected %q, got %q", expected, result) 58 + } 59 + } 60 + 61 + func TestSecretMask_MultipleOccurrences(t *testing.T) { 62 + mask := NewSecretMask([]string{"secret"}) 63 + 64 + input := "secret appears twice: secret" 65 + expected := "*** appears twice: ***" 66 + 67 + result := mask.Mask(input) 68 + if result != expected { 69 + t.Errorf("expected %q, got %q", expected, result) 70 + } 71 + } 72 + 73 + func TestSecretMask_ShortValues(t *testing.T) { 74 + mask := NewSecretMask([]string{"abc", "xy", ""}) 75 + 76 + if mask == nil { 77 + t.Fatal("expected non-nil mask") 78 + } 79 + 80 + input := "abc xy test" 81 + expected := "*** *** test" 82 + result := mask.Mask(input) 83 + if result != expected { 84 + t.Errorf("expected %q, got %q", expected, result) 85 + } 86 + } 87 + 88 + func TestSecretMask_NilMask(t *testing.T) { 89 + var mask *SecretMask 90 + 91 + input := "some input text" 92 + result := mask.Mask(input) 93 + if result != input { 94 + t.Errorf("expected %q, got %q", input, result) 95 + } 96 + } 97 + 98 + func TestSecretMask_EmptyInput(t *testing.T) { 99 + mask := NewSecretMask([]string{"secret"}) 100 + 101 + result := mask.Mask("") 102 + if result != "" { 103 + t.Errorf("expected empty string, got %q", result) 104 + } 105 + } 106 + 107 + func TestSecretMask_NoMatch(t *testing.T) { 108 + mask := NewSecretMask([]string{"secretvalue"}) 109 + 110 + input := "nothing to mask here" 111 + result := mask.Mask(input) 112 + if result != input { 113 + t.Errorf("expected %q, got %q", input, result) 114 + } 115 + } 116 + 117 + func TestSecretMask_EmptySecretsList(t *testing.T) { 118 + mask := NewSecretMask([]string{}) 119 + 120 + if mask != nil { 121 + t.Error("expected nil mask for empty secrets list") 122 + } 123 + } 124 + 125 + func TestSecretMask_EmptySecretsFiltered(t *testing.T) { 126 + mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"}) 127 + 128 + input := "Using validpassword here" 129 + expected := "Using *** here" 130 + 131 + result := mask.Mask(input) 132 + if result != expected { 133 + t.Errorf("expected %q, got %q", expected, result) 134 + } 135 + }
+1 -1
spindle/motd
··· 20 ** 21 ******** 22 23 - This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle 24 25 Most API routes are under /xrpc/
··· 20 ** 21 ******** 22 23 + This is a spindle server. More info at https://docs.tangled.org/spindles.html#spindles 24 25 Most API routes are under /xrpc/
+36 -17
spindle/server.go
··· 8 "log/slog" 9 "maps" 10 "net/http" 11 12 "github.com/go-chi/chi/v5" 13 "tangled.org/core/api/tangled" ··· 30 ) 31 32 //go:embed motd 33 - var motd []byte 34 35 const ( 36 rbacDomain = "thisserver" 37 ) 38 39 type Spindle struct { 40 - jc *jetstream.JetstreamClient 41 - db *db.DB 42 - e *rbac.Enforcer 43 - l *slog.Logger 44 - n *notifier.Notifier 45 - engs map[string]models.Engine 46 - jq *queue.Queue 47 - cfg *config.Config 48 - ks *eventconsumer.Consumer 49 - res *idresolver.Resolver 50 - vault secrets.Manager 51 } 52 53 // New creates a new Spindle server with the provided configuration and engines. ··· 128 cfg: cfg, 129 res: resolver, 130 vault: vault, 131 } 132 133 err = e.AddSpindle(rbacDomain) ··· 201 return s.e 202 } 203 204 // Start starts the Spindle server (blocking). 205 func (s *Spindle) Start(ctx context.Context) error { 206 // starts a job queue runner in the background ··· 246 mux := chi.NewRouter() 247 248 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 249 - w.Write(motd) 250 }) 251 mux.HandleFunc("/events", s.Events) 252 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) ··· 268 Config: s.cfg, 269 Resolver: s.res, 270 Vault: s.vault, 271 ServiceAuth: serviceAuth, 272 } 273 ··· 302 tpl.TriggerMetadata.Repo.Repo, 303 ) 304 if err != nil { 305 - return err 306 } 307 308 pipelineId := models.PipelineId{ ··· 323 Name: w.Name, 324 }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 325 if err != nil { 326 - return err 327 } 328 329 continue ··· 337 338 ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 339 if err != nil { 340 - return err 341 } 342 343 // inject TANGLED_* env vars after InitWorkflow ··· 354 Name: w.Name, 355 }, s.n) 356 if err != nil { 357 - return err 358 } 359 } 360 }
··· 8 "log/slog" 9 "maps" 10 "net/http" 11 + "sync" 12 13 "github.com/go-chi/chi/v5" 14 "tangled.org/core/api/tangled" ··· 31 ) 32 33 //go:embed motd 34 + var defaultMotd []byte 35 36 const ( 37 rbacDomain = "thisserver" 38 ) 39 40 type Spindle struct { 41 + jc *jetstream.JetstreamClient 42 + db *db.DB 43 + e *rbac.Enforcer 44 + l *slog.Logger 45 + n *notifier.Notifier 46 + engs map[string]models.Engine 47 + jq *queue.Queue 48 + cfg *config.Config 49 + ks *eventconsumer.Consumer 50 + res *idresolver.Resolver 51 + vault secrets.Manager 52 + motd []byte 53 + motdMu sync.RWMutex 54 } 55 56 // New creates a new Spindle server with the provided configuration and engines. ··· 131 cfg: cfg, 132 res: resolver, 133 vault: vault, 134 + motd: defaultMotd, 135 } 136 137 err = e.AddSpindle(rbacDomain) ··· 205 return s.e 206 } 207 208 + // SetMotdContent sets custom MOTD content, replacing the embedded default. 209 + func (s *Spindle) SetMotdContent(content []byte) { 210 + s.motdMu.Lock() 211 + defer s.motdMu.Unlock() 212 + s.motd = content 213 + } 214 + 215 + // GetMotdContent returns the current MOTD content. 216 + func (s *Spindle) GetMotdContent() []byte { 217 + s.motdMu.RLock() 218 + defer s.motdMu.RUnlock() 219 + return s.motd 220 + } 221 + 222 // Start starts the Spindle server (blocking). 223 func (s *Spindle) Start(ctx context.Context) error { 224 // starts a job queue runner in the background ··· 264 mux := chi.NewRouter() 265 266 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 267 + w.Write(s.GetMotdContent()) 268 }) 269 mux.HandleFunc("/events", s.Events) 270 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) ··· 286 Config: s.cfg, 287 Resolver: s.res, 288 Vault: s.vault, 289 + Notifier: s.Notifier(), 290 ServiceAuth: serviceAuth, 291 } 292 ··· 321 tpl.TriggerMetadata.Repo.Repo, 322 ) 323 if err != nil { 324 + return fmt.Errorf("failed to get repo: %w", err) 325 } 326 327 pipelineId := models.PipelineId{ ··· 342 Name: w.Name, 343 }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 344 if err != nil { 345 + return fmt.Errorf("db.StatusFailed: %w", err) 346 } 347 348 continue ··· 356 357 ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 358 if err != nil { 359 + return fmt.Errorf("init workflow: %w", err) 360 } 361 362 // inject TANGLED_* env vars after InitWorkflow ··· 373 Name: w.Name, 374 }, s.n) 375 if err != nil { 376 + return fmt.Errorf("db.StatusPending: %w", err) 377 } 378 } 379 }
+97
spindle/xrpc/pipeline_cancelPipeline.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + securejoin "github.com/cyphar/filepath-securejoin" 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/rbac" 15 + "tangled.org/core/spindle/models" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) CancelPipeline(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + l.Debug("cancel pipeline") 26 + 27 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 28 + if !ok { 29 + fail(xrpcerr.MissingActorDidError) 30 + return 31 + } 32 + 33 + var input tangled.PipelineCancelPipeline_Input 34 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 35 + fail(xrpcerr.GenericError(err)) 36 + return 37 + } 38 + 39 + aturi := syntax.ATURI(input.Pipeline) 40 + wid := models.WorkflowId{ 41 + PipelineId: models.PipelineId{ 42 + Knot: strings.TrimPrefix(aturi.Authority().String(), "did:web:"), 43 + Rkey: aturi.RecordKey().String(), 44 + }, 45 + Name: input.Workflow, 46 + } 47 + l.Debug("cancel pipeline", "wid", wid) 48 + 49 + // unfortunately we have to resolve repo-at here 50 + repoAt, err := syntax.ParseATURI(input.Repo) 51 + if err != nil { 52 + fail(xrpcerr.InvalidRepoError(input.Repo)) 53 + return 54 + } 55 + 56 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 57 + if err != nil || ident.Handle.IsInvalidHandle() { 58 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 59 + return 60 + } 61 + 62 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 63 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 64 + if err != nil { 65 + fail(xrpcerr.GenericError(err)) 66 + return 67 + } 68 + 69 + repo := resp.Value.Val.(*tangled.Repo) 70 + didSlashRepo, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 71 + if err != nil { 72 + fail(xrpcerr.GenericError(err)) 73 + return 74 + } 75 + 76 + // TODO: fine-grained role based control 77 + isRepoOwner, err := x.Enforcer.IsRepoOwner(actorDid.String(), rbac.ThisServer, didSlashRepo) 78 + if err != nil || !isRepoOwner { 79 + fail(xrpcerr.AccessControlError(actorDid.String())) 80 + return 81 + } 82 + for _, engine := range x.Engines { 83 + l.Debug("destorying workflow", "wid", wid) 84 + err = engine.DestroyWorkflow(r.Context(), wid) 85 + if err != nil { 86 + fail(xrpcerr.GenericError(fmt.Errorf("failed to destroy workflow: %w", err))) 87 + return 88 + } 89 + err = x.Db.StatusCancelled(wid, "User canceled the workflow", -1, x.Notifier) 90 + if err != nil { 91 + fail(xrpcerr.GenericError(fmt.Errorf("failed to emit status failed: %w", err))) 92 + return 93 + } 94 + } 95 + 96 + w.WriteHeader(http.StatusOK) 97 + }
+3
spindle/xrpc/xrpc.go
··· 10 11 "tangled.org/core/api/tangled" 12 "tangled.org/core/idresolver" 13 "tangled.org/core/rbac" 14 "tangled.org/core/spindle/config" 15 "tangled.org/core/spindle/db" ··· 29 Config *config.Config 30 Resolver *idresolver.Resolver 31 Vault secrets.Manager 32 ServiceAuth *serviceauth.ServiceAuth 33 } 34 ··· 41 r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 42 r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 43 r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 44 }) 45 46 // service query endpoints (no auth required)
··· 10 11 "tangled.org/core/api/tangled" 12 "tangled.org/core/idresolver" 13 + "tangled.org/core/notifier" 14 "tangled.org/core/rbac" 15 "tangled.org/core/spindle/config" 16 "tangled.org/core/spindle/db" ··· 30 Config *config.Config 31 Resolver *idresolver.Resolver 32 Vault secrets.Manager 33 + Notifier *notifier.Notifier 34 ServiceAuth *serviceauth.ServiceAuth 35 } 36 ··· 43 r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 44 r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 45 r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 46 + r.Post("/"+tangled.PipelineCancelPipelineNSID, x.CancelPipeline) 47 }) 48 49 // service query endpoints (no auth required)
+1 -1
tailwind.config.js
··· 2 const colors = require("tailwindcss/colors"); 3 4 module.exports = { 5 - content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"], 6 darkMode: "media", 7 theme: { 8 container: {
··· 2 const colors = require("tailwindcss/colors"); 3 4 module.exports = { 5 + content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go", "./docs/*.html"], 6 darkMode: "media", 7 theme: { 8 container: {
+81 -30
types/diff.go
··· 1 package types 2 3 import ( 4 "github.com/bluekeyes/go-gitdiff/gitdiff" 5 ) 6 7 type DiffOpts struct { 8 Split bool `json:"split"` 9 } 10 11 - type TextFragment struct { 12 - Header string `json:"comment"` 13 - Lines []gitdiff.Line `json:"lines"` 14 } 15 16 type Diff struct { ··· 26 IsRename bool `json:"is_rename"` 27 } 28 29 - type DiffStat struct { 30 - Insertions int64 31 - Deletions int64 32 - } 33 - 34 - func (d *Diff) Stats() DiffStat { 35 - var stats DiffStat 36 for _, f := range d.TextFragments { 37 stats.Insertions += f.LinesAdded 38 stats.Deletions += f.LinesDeleted ··· 40 return stats 41 } 42 43 - // A nicer git diff representation. 44 - type NiceDiff struct { 45 - Commit Commit `json:"commit"` 46 - Stat struct { 47 - FilesChanged int `json:"files_changed"` 48 - Insertions int `json:"insertions"` 49 - Deletions int `json:"deletions"` 50 - } `json:"stat"` 51 - Diff []Diff `json:"diff"` 52 } 53 54 type DiffTree struct { ··· 58 Diff []*gitdiff.File `json:"diff"` 59 } 60 61 - func (d *NiceDiff) ChangedFiles() []string { 62 - files := make([]string, len(d.Diff)) 63 64 - for i, f := range d.Diff { 65 - if f.IsDelete { 66 - files[i] = f.Name.Old 67 } else { 68 - files[i] = f.Name.New 69 } 70 } 71 72 - return files 73 } 74 75 - // used by html elements as a unique ID for hrefs 76 - func (d *Diff) Id() string { 77 return d.Name.New 78 } 79 80 - func (d *Diff) Split() *SplitDiff { 81 fragments := make([]SplitFragment, len(d.TextFragments)) 82 for i, fragment := range d.TextFragments { 83 leftLines, rightLines := SeparateLines(&fragment) ··· 88 } 89 } 90 91 - return &SplitDiff{ 92 Name: d.Id(), 93 TextFragments: fragments, 94 }
··· 1 package types 2 3 import ( 4 + "net/url" 5 + 6 "github.com/bluekeyes/go-gitdiff/gitdiff" 7 + "tangled.org/core/appview/filetree" 8 ) 9 10 type DiffOpts struct { 11 Split bool `json:"split"` 12 } 13 14 + func (d DiffOpts) Encode() string { 15 + values := make(url.Values) 16 + if d.Split { 17 + values.Set("diff", "split") 18 + } else { 19 + values.Set("diff", "unified") 20 + } 21 + return values.Encode() 22 + } 23 + 24 + // A nicer git diff representation. 25 + type NiceDiff struct { 26 + Commit Commit `json:"commit"` 27 + Stat DiffStat `json:"stat"` 28 + Diff []Diff `json:"diff"` 29 } 30 31 type Diff struct { ··· 41 IsRename bool `json:"is_rename"` 42 } 43 44 + func (d Diff) Stats() DiffFileStat { 45 + var stats DiffFileStat 46 for _, f := range d.TextFragments { 47 stats.Insertions += f.LinesAdded 48 stats.Deletions += f.LinesDeleted ··· 50 return stats 51 } 52 53 + type DiffStat struct { 54 + Insertions int64 `json:"insertions"` 55 + Deletions int64 `json:"deletions"` 56 + FilesChanged int `json:"files_changed"` 57 + } 58 + 59 + type DiffFileStat struct { 60 + Insertions int64 61 + Deletions int64 62 } 63 64 type DiffTree struct { ··· 68 Diff []*gitdiff.File `json:"diff"` 69 } 70 71 + type DiffFileName struct { 72 + Old string 73 + New string 74 + } 75 + 76 + func (d NiceDiff) ChangedFiles() []DiffFileRenderer { 77 + drs := make([]DiffFileRenderer, len(d.Diff)) 78 + for i, s := range d.Diff { 79 + drs[i] = s 80 + } 81 + return drs 82 + } 83 84 + func (d NiceDiff) FileTree() *filetree.FileTreeNode { 85 + fs := make([]string, len(d.Diff)) 86 + for i, s := range d.Diff { 87 + n := s.Names() 88 + if n.New == "" { 89 + fs[i] = n.Old 90 } else { 91 + fs[i] = n.New 92 } 93 } 94 + return filetree.FileTree(fs) 95 + } 96 97 + func (d NiceDiff) Stats() DiffStat { 98 + return d.Stat 99 } 100 101 + func (d Diff) Id() string { 102 + if d.IsDelete { 103 + return d.Name.Old 104 + } 105 return d.Name.New 106 } 107 108 + func (d Diff) Names() DiffFileName { 109 + var n DiffFileName 110 + if d.IsDelete { 111 + n.Old = d.Name.Old 112 + return n 113 + } else if d.IsCopy || d.IsRename { 114 + n.Old = d.Name.Old 115 + n.New = d.Name.New 116 + return n 117 + } else { 118 + n.New = d.Name.New 119 + return n 120 + } 121 + } 122 + 123 + func (d Diff) CanRender() string { 124 + if d.IsBinary { 125 + return "This is a binary file and will not be displayed." 126 + } 127 + 128 + return "" 129 + } 130 + 131 + func (d Diff) Split() SplitDiff { 132 fragments := make([]SplitFragment, len(d.TextFragments)) 133 for i, fragment := range d.TextFragments { 134 leftLines, rightLines := SeparateLines(&fragment) ··· 139 } 140 } 141 142 + return SplitDiff{ 143 Name: d.Id(), 144 TextFragments: fragments, 145 }
+31
types/diff_renderer.go
···
··· 1 + package types 2 + 3 + import "tangled.org/core/appview/filetree" 4 + 5 + type DiffRenderer interface { 6 + // list of file affected by these diffs 7 + ChangedFiles() []DiffFileRenderer 8 + 9 + // filetree 10 + FileTree() *filetree.FileTreeNode 11 + 12 + Stats() DiffStat 13 + } 14 + 15 + type DiffFileRenderer interface { 16 + // html ID for each file in the diff 17 + Id() string 18 + 19 + // produce a splitdiff 20 + Split() SplitDiff 21 + 22 + // stats for this single file 23 + Stats() DiffFileStat 24 + 25 + // old and new name of file 26 + Names() DiffFileName 27 + 28 + // whether this diff can be displayed, 29 + // returns a reason if not, and the empty string if it can 30 + CanRender() string 31 + }
+121
types/diff_test.go
···
··· 1 + package types 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestDiffId(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + diff Diff 11 + expected string 12 + }{ 13 + { 14 + name: "regular file uses new name", 15 + diff: Diff{ 16 + Name: struct { 17 + Old string `json:"old"` 18 + New string `json:"new"` 19 + }{Old: "", New: "src/main.go"}, 20 + }, 21 + expected: "src/main.go", 22 + }, 23 + { 24 + name: "new file uses new name", 25 + diff: Diff{ 26 + Name: struct { 27 + Old string `json:"old"` 28 + New string `json:"new"` 29 + }{Old: "", New: "src/new.go"}, 30 + IsNew: true, 31 + }, 32 + expected: "src/new.go", 33 + }, 34 + { 35 + name: "deleted file uses old name", 36 + diff: Diff{ 37 + Name: struct { 38 + Old string `json:"old"` 39 + New string `json:"new"` 40 + }{Old: "src/deleted.go", New: ""}, 41 + IsDelete: true, 42 + }, 43 + expected: "src/deleted.go", 44 + }, 45 + { 46 + name: "renamed file uses new name", 47 + diff: Diff{ 48 + Name: struct { 49 + Old string `json:"old"` 50 + New string `json:"new"` 51 + }{Old: "src/old.go", New: "src/renamed.go"}, 52 + IsRename: true, 53 + }, 54 + expected: "src/renamed.go", 55 + }, 56 + } 57 + 58 + for _, tt := range tests { 59 + t.Run(tt.name, func(t *testing.T) { 60 + if got := tt.diff.Id(); got != tt.expected { 61 + t.Errorf("Diff.Id() = %q, want %q", got, tt.expected) 62 + } 63 + }) 64 + } 65 + } 66 + 67 + func TestChangedFilesMatchesDiffId(t *testing.T) { 68 + // ChangedFiles() must return values matching each Diff's Id() 69 + // so that sidebar links point to the correct anchors. 70 + // Tests existing, deleted, new, and renamed files. 71 + nd := NiceDiff{ 72 + Diff: []Diff{ 73 + { 74 + Name: struct { 75 + Old string `json:"old"` 76 + New string `json:"new"` 77 + }{Old: "", New: "src/modified.go"}, 78 + }, 79 + { 80 + Name: struct { 81 + Old string `json:"old"` 82 + New string `json:"new"` 83 + }{Old: "src/deleted.go", New: ""}, 84 + IsDelete: true, 85 + }, 86 + { 87 + Name: struct { 88 + Old string `json:"old"` 89 + New string `json:"new"` 90 + }{Old: "", New: "src/new.go"}, 91 + IsNew: true, 92 + }, 93 + { 94 + Name: struct { 95 + Old string `json:"old"` 96 + New string `json:"new"` 97 + }{Old: "src/old.go", New: "src/renamed.go"}, 98 + IsRename: true, 99 + }, 100 + }, 101 + } 102 + 103 + changedFiles := nd.ChangedFiles() 104 + 105 + if len(changedFiles) != len(nd.Diff) { 106 + t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff)) 107 + } 108 + 109 + for i, diff := range nd.Diff { 110 + if changedFiles[i].Id() != diff.Id() { 111 + t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id()) 112 + } 113 + } 114 + } 115 + 116 + func TestImplsInterfaces(t *testing.T) { 117 + nd := NiceDiff{} 118 + _ = isDiffsRenderer(nd) 119 + } 120 + 121 + func isDiffsRenderer[S DiffRenderer](S) bool { return true }
+1 -2
types/split.go
··· 22 TextFragments []SplitFragment `json:"fragments"` 23 } 24 25 - // used by html elements as a unique ID for hrefs 26 - func (d *SplitDiff) Id() string { 27 return d.Name 28 } 29
··· 22 TextFragments []SplitFragment `json:"fragments"` 23 } 24 25 + func (d SplitDiff) Id() string { 26 return d.Name 27 } 28