+1
-20
.tangled/workflows/test.yml
+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:
+79
-20
api/tangled/cbor_gen.go
+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
+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
+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
-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 (
+6
-6
appview/db/pipeline.go
+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
+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
+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
+1
-1
appview/db/punchcard.go
+1
appview/db/repos.go
+1
appview/db/repos.go
+32
-32
appview/issues/issues.go
+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
+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
+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
+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
+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
+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
+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
+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
+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), ®istry); err != nil {
57
+
return &AccountRegistry{Accounts: []AccountInfo{}}
58
+
}
59
+
60
+
return ®istry
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
+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
+4
appview/oauth/consts.go
+14
-2
appview/oauth/handler.go
+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
+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
+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
+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
+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
+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
+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
+9
-29
appview/pages/templates/brand/brand.html
+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
+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
-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
-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
-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
+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
-5
appview/pages/templates/fragments/starBtn-oob.html
+1
appview/pages/templates/fragments/starBtn.html
+1
appview/pages/templates/fragments/starBtn.html
+7
-5
appview/pages/templates/fragments/tinyAvatarList.html
+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
+1
-1
appview/pages/templates/knots/index.html
+30
-17
appview/pages/templates/labels/fragments/label.html
+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
+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
+
<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" />
+50
-16
appview/pages/templates/layouts/fragments/topbar.html
+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
-1
appview/pages/templates/layouts/repobase.html
+1
-18
appview/pages/templates/repo/commit.html
+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}}
+1
-19
appview/pages/templates/repo/compare/compare.html
+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}}
+1
-1
appview/pages/templates/repo/empty.html
+1
-1
appview/pages/templates/repo/empty.html
+81
-96
appview/pages/templates/repo/fragments/cloneDropdown.html
+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
+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
-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
+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
-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
-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
+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">···</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">···</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">···</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">···</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
+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">···</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">···</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
+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
+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
-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
+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
+2
-2
appview/pages/templates/repo/issues/fragments/issueListing.html
+1
-1
appview/pages/templates/repo/issues/fragments/putIssue.html
+1
-1
appview/pages/templates/repo/issues/fragments/putIssue.html
+3
-3
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
+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
+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
+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
+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
+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" (dict "Pipeline" $pipeline "ShortSummary" true) }}
8
</summary>
9
{{ template "repo/pipelines/fragments/tooltip" $ }}
10
</details>
+1
-1
appview/pages/templates/repo/pipelines/pipelines.html
+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>
+14
appview/pages/templates/repo/pipelines/workflow.html
+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
+
<!-- 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
+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
+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
+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
+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
+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
+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
+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
+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
+1
-1
appview/pages/templates/spindles/index.html
+6
appview/pages/templates/user/fragments/follow-oob.html
+6
appview/pages/templates/user/fragments/follow-oob.html
+7
-5
appview/pages/templates/user/fragments/followCard.html
+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
+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
+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
+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
+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
+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
+1
-1
appview/pulls/router.go
···
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
+1
appview/repo/archive.go
+6
-6
appview/repo/artifact.go
+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
+1
-1
appview/repo/blob.go
+1
-1
appview/repo/branches.go
+1
-1
appview/repo/branches.go
+2
-2
appview/repo/compare.go
+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
+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
+1
-1
appview/repo/index.go
+2
-2
appview/repo/log.go
+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
+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
+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
+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/tree.go
+1
-1
appview/repo/tree.go
+30
-5
appview/reporesolver/resolver.go
+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
+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
+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
+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
+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
+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
+1
-1
appview/state/gfi.go
+57
-7
appview/state/login.go
+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
+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
+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 ¶ms, 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 ¶ms, 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
+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
+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
+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
+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
+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
+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
+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
-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
-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
+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
-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
+6
docs/logo.html
-59
docs/migrations.md
-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
+3
docs/mode.html
+7
docs/search.html
+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
-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
-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
-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
-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
+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
+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
+4
docs/toc.html
+3
-3
flake.lock
+3
-3
flake.lock
+21
-3
flake.nix
+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
+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
+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
+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
+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
+10
-2
lexicons/pulls/pull.json
···
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
+3
nix/modules/appview.nix
+6
-1
nix/pkgs/appview-static-files.nix
+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
+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
+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
+1
-1
nix/vm.nix
+66
-10
patchutil/interdiff.go
+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
+9
patchutil/patchutil_test.go
···
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
+3
-3
readme.md
···
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
+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
+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
+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
+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
+2
-2
spindle/models/models.go
+1
-1
spindle/models/pipeline_env.go
+1
-1
spindle/models/pipeline_env.go
+51
spindle/models/secret_mask.go
+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
+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
+1
-1
spindle/motd
+36
-17
spindle/server.go
+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
+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
+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
+1
-1
tailwind.config.js
+81
-30
types/diff.go
+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
+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
+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
+1
-2
types/split.go