-416
api/tangled/cbor_gen.go
-416
api/tangled/cbor_gen.go
···
561
562
return nil
563
}
564
-
func (t *Comment) MarshalCBOR(w io.Writer) error {
565
-
if t == nil {
566
-
_, err := w.Write(cbg.CborNull)
567
-
return err
568
-
}
569
-
570
-
cw := cbg.NewCborWriter(w)
571
-
fieldCount := 7
572
-
573
-
if t.Mentions == nil {
574
-
fieldCount--
575
-
}
576
-
577
-
if t.References == nil {
578
-
fieldCount--
579
-
}
580
-
581
-
if t.ReplyTo == nil {
582
-
fieldCount--
583
-
}
584
-
585
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
586
-
return err
587
-
}
588
-
589
-
// t.Body (string) (string)
590
-
if len("body") > 1000000 {
591
-
return xerrors.Errorf("Value in field \"body\" was too long")
592
-
}
593
-
594
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil {
595
-
return err
596
-
}
597
-
if _, err := cw.WriteString(string("body")); err != nil {
598
-
return err
599
-
}
600
-
601
-
if len(t.Body) > 1000000 {
602
-
return xerrors.Errorf("Value in field t.Body was too long")
603
-
}
604
-
605
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil {
606
-
return err
607
-
}
608
-
if _, err := cw.WriteString(string(t.Body)); err != nil {
609
-
return err
610
-
}
611
-
612
-
// t.LexiconTypeID (string) (string)
613
-
if len("$type") > 1000000 {
614
-
return xerrors.Errorf("Value in field \"$type\" was too long")
615
-
}
616
-
617
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
618
-
return err
619
-
}
620
-
if _, err := cw.WriteString(string("$type")); err != nil {
621
-
return err
622
-
}
623
-
624
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.comment"))); err != nil {
625
-
return err
626
-
}
627
-
if _, err := cw.WriteString(string("sh.tangled.comment")); err != nil {
628
-
return err
629
-
}
630
-
631
-
// t.ReplyTo (string) (string)
632
-
if t.ReplyTo != nil {
633
-
634
-
if len("replyTo") > 1000000 {
635
-
return xerrors.Errorf("Value in field \"replyTo\" was too long")
636
-
}
637
-
638
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil {
639
-
return err
640
-
}
641
-
if _, err := cw.WriteString(string("replyTo")); err != nil {
642
-
return err
643
-
}
644
-
645
-
if t.ReplyTo == nil {
646
-
if _, err := cw.Write(cbg.CborNull); err != nil {
647
-
return err
648
-
}
649
-
} else {
650
-
if len(*t.ReplyTo) > 1000000 {
651
-
return xerrors.Errorf("Value in field t.ReplyTo was too long")
652
-
}
653
-
654
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil {
655
-
return err
656
-
}
657
-
if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil {
658
-
return err
659
-
}
660
-
}
661
-
}
662
-
663
-
// t.Subject (string) (string)
664
-
if len("subject") > 1000000 {
665
-
return xerrors.Errorf("Value in field \"subject\" was too long")
666
-
}
667
-
668
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
669
-
return err
670
-
}
671
-
if _, err := cw.WriteString(string("subject")); err != nil {
672
-
return err
673
-
}
674
-
675
-
if len(t.Subject) > 1000000 {
676
-
return xerrors.Errorf("Value in field t.Subject was too long")
677
-
}
678
-
679
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
680
-
return err
681
-
}
682
-
if _, err := cw.WriteString(string(t.Subject)); err != nil {
683
-
return err
684
-
}
685
-
686
-
// t.Mentions ([]string) (slice)
687
-
if t.Mentions != nil {
688
-
689
-
if len("mentions") > 1000000 {
690
-
return xerrors.Errorf("Value in field \"mentions\" was too long")
691
-
}
692
-
693
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
694
-
return err
695
-
}
696
-
if _, err := cw.WriteString(string("mentions")); err != nil {
697
-
return err
698
-
}
699
-
700
-
if len(t.Mentions) > 8192 {
701
-
return xerrors.Errorf("Slice value in field t.Mentions was too long")
702
-
}
703
-
704
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
705
-
return err
706
-
}
707
-
for _, v := range t.Mentions {
708
-
if len(v) > 1000000 {
709
-
return xerrors.Errorf("Value in field v was too long")
710
-
}
711
-
712
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
713
-
return err
714
-
}
715
-
if _, err := cw.WriteString(string(v)); err != nil {
716
-
return err
717
-
}
718
-
719
-
}
720
-
}
721
-
722
-
// t.CreatedAt (string) (string)
723
-
if len("createdAt") > 1000000 {
724
-
return xerrors.Errorf("Value in field \"createdAt\" was too long")
725
-
}
726
-
727
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
728
-
return err
729
-
}
730
-
if _, err := cw.WriteString(string("createdAt")); err != nil {
731
-
return err
732
-
}
733
-
734
-
if len(t.CreatedAt) > 1000000 {
735
-
return xerrors.Errorf("Value in field t.CreatedAt was too long")
736
-
}
737
-
738
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
739
-
return err
740
-
}
741
-
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
742
-
return err
743
-
}
744
-
745
-
// t.References ([]string) (slice)
746
-
if t.References != nil {
747
-
748
-
if len("references") > 1000000 {
749
-
return xerrors.Errorf("Value in field \"references\" was too long")
750
-
}
751
-
752
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil {
753
-
return err
754
-
}
755
-
if _, err := cw.WriteString(string("references")); err != nil {
756
-
return err
757
-
}
758
-
759
-
if len(t.References) > 8192 {
760
-
return xerrors.Errorf("Slice value in field t.References was too long")
761
-
}
762
-
763
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil {
764
-
return err
765
-
}
766
-
for _, v := range t.References {
767
-
if len(v) > 1000000 {
768
-
return xerrors.Errorf("Value in field v was too long")
769
-
}
770
-
771
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
772
-
return err
773
-
}
774
-
if _, err := cw.WriteString(string(v)); err != nil {
775
-
return err
776
-
}
777
-
778
-
}
779
-
}
780
-
return nil
781
-
}
782
-
783
-
func (t *Comment) UnmarshalCBOR(r io.Reader) (err error) {
784
-
*t = Comment{}
785
-
786
-
cr := cbg.NewCborReader(r)
787
-
788
-
maj, extra, err := cr.ReadHeader()
789
-
if err != nil {
790
-
return err
791
-
}
792
-
defer func() {
793
-
if err == io.EOF {
794
-
err = io.ErrUnexpectedEOF
795
-
}
796
-
}()
797
-
798
-
if maj != cbg.MajMap {
799
-
return fmt.Errorf("cbor input should be of type map")
800
-
}
801
-
802
-
if extra > cbg.MaxLength {
803
-
return fmt.Errorf("Comment: map struct too large (%d)", extra)
804
-
}
805
-
806
-
n := extra
807
-
808
-
nameBuf := make([]byte, 10)
809
-
for i := uint64(0); i < n; i++ {
810
-
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
811
-
if err != nil {
812
-
return err
813
-
}
814
-
815
-
if !ok {
816
-
// Field doesn't exist on this type, so ignore it
817
-
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
818
-
return err
819
-
}
820
-
continue
821
-
}
822
-
823
-
switch string(nameBuf[:nameLen]) {
824
-
// t.Body (string) (string)
825
-
case "body":
826
-
827
-
{
828
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
829
-
if err != nil {
830
-
return err
831
-
}
832
-
833
-
t.Body = string(sval)
834
-
}
835
-
// t.LexiconTypeID (string) (string)
836
-
case "$type":
837
-
838
-
{
839
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
840
-
if err != nil {
841
-
return err
842
-
}
843
-
844
-
t.LexiconTypeID = string(sval)
845
-
}
846
-
// t.ReplyTo (string) (string)
847
-
case "replyTo":
848
-
849
-
{
850
-
b, err := cr.ReadByte()
851
-
if err != nil {
852
-
return err
853
-
}
854
-
if b != cbg.CborNull[0] {
855
-
if err := cr.UnreadByte(); err != nil {
856
-
return err
857
-
}
858
-
859
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
860
-
if err != nil {
861
-
return err
862
-
}
863
-
864
-
t.ReplyTo = (*string)(&sval)
865
-
}
866
-
}
867
-
// t.Subject (string) (string)
868
-
case "subject":
869
-
870
-
{
871
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
872
-
if err != nil {
873
-
return err
874
-
}
875
-
876
-
t.Subject = string(sval)
877
-
}
878
-
// t.Mentions ([]string) (slice)
879
-
case "mentions":
880
-
881
-
maj, extra, err = cr.ReadHeader()
882
-
if err != nil {
883
-
return err
884
-
}
885
-
886
-
if extra > 8192 {
887
-
return fmt.Errorf("t.Mentions: array too large (%d)", extra)
888
-
}
889
-
890
-
if maj != cbg.MajArray {
891
-
return fmt.Errorf("expected cbor array")
892
-
}
893
-
894
-
if extra > 0 {
895
-
t.Mentions = make([]string, extra)
896
-
}
897
-
898
-
for i := 0; i < int(extra); i++ {
899
-
{
900
-
var maj byte
901
-
var extra uint64
902
-
var err error
903
-
_ = maj
904
-
_ = extra
905
-
_ = err
906
-
907
-
{
908
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
909
-
if err != nil {
910
-
return err
911
-
}
912
-
913
-
t.Mentions[i] = string(sval)
914
-
}
915
-
916
-
}
917
-
}
918
-
// t.CreatedAt (string) (string)
919
-
case "createdAt":
920
-
921
-
{
922
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
923
-
if err != nil {
924
-
return err
925
-
}
926
-
927
-
t.CreatedAt = string(sval)
928
-
}
929
-
// t.References ([]string) (slice)
930
-
case "references":
931
-
932
-
maj, extra, err = cr.ReadHeader()
933
-
if err != nil {
934
-
return err
935
-
}
936
-
937
-
if extra > 8192 {
938
-
return fmt.Errorf("t.References: array too large (%d)", extra)
939
-
}
940
-
941
-
if maj != cbg.MajArray {
942
-
return fmt.Errorf("expected cbor array")
943
-
}
944
-
945
-
if extra > 0 {
946
-
t.References = make([]string, extra)
947
-
}
948
-
949
-
for i := 0; i < int(extra); i++ {
950
-
{
951
-
var maj byte
952
-
var extra uint64
953
-
var err error
954
-
_ = maj
955
-
_ = extra
956
-
_ = err
957
-
958
-
{
959
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
960
-
if err != nil {
961
-
return err
962
-
}
963
-
964
-
t.References[i] = string(sval)
965
-
}
966
-
967
-
}
968
-
}
969
-
970
-
default:
971
-
// Field doesn't exist on this type, so ignore it
972
-
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
973
-
return err
974
-
}
975
-
}
976
-
}
977
-
978
-
return nil
979
-
}
980
func (t *FeedReaction) MarshalCBOR(w io.Writer) error {
981
if t == nil {
982
_, err := w.Write(cbg.CborNull)
-27
api/tangled/tangledcomment.go
-27
api/tangled/tangledcomment.go
···
1
-
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
-
3
-
package tangled
4
-
5
-
// schema: sh.tangled.comment
6
-
7
-
import (
8
-
"github.com/bluesky-social/indigo/lex/util"
9
-
)
10
-
11
-
const (
12
-
CommentNSID = "sh.tangled.comment"
13
-
)
14
-
15
-
func init() {
16
-
util.RegisterType("sh.tangled.comment", &Comment{})
17
-
} //
18
-
// RECORDTYPE: Comment
19
-
type Comment struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.comment" cborgen:"$type,const=sh.tangled.comment"`
21
-
Body string `json:"body" cborgen:"body"`
22
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
-
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
25
-
ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"`
26
-
Subject string `json:"subject" cborgen:"subject"`
27
-
}
···
-199
appview/db/comments.go
-199
appview/db/comments.go
···
1
-
package db
2
-
3
-
import (
4
-
"database/sql"
5
-
"fmt"
6
-
"maps"
7
-
"slices"
8
-
"sort"
9
-
"strings"
10
-
"time"
11
-
12
-
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.org/core/appview/models"
14
-
"tangled.org/core/orm"
15
-
)
16
-
17
-
func PutComment(tx *sql.Tx, c *models.Comment) error {
18
-
result, err := tx.Exec(
19
-
`insert into comments (
20
-
did,
21
-
rkey,
22
-
subject_at,
23
-
reply_to,
24
-
body,
25
-
pull_submission_id,
26
-
created
27
-
)
28
-
values (?, ?, ?, ?, ?, ?, ?)
29
-
on conflict(did, rkey) do update set
30
-
subject_at = excluded.subject_at,
31
-
reply_to = excluded.reply_to,
32
-
body = excluded.body,
33
-
edited = case
34
-
when
35
-
comments.subject_at != excluded.subject_at
36
-
or comments.body != excluded.body
37
-
or comments.reply_to != excluded.reply_to
38
-
then ?
39
-
else comments.edited
40
-
end`,
41
-
c.Did,
42
-
c.Rkey,
43
-
c.Subject,
44
-
c.ReplyTo,
45
-
c.Body,
46
-
c.PullSubmissionId,
47
-
c.Created.Format(time.RFC3339),
48
-
time.Now().Format(time.RFC3339),
49
-
)
50
-
if err != nil {
51
-
return err
52
-
}
53
-
54
-
c.Id, err = result.LastInsertId()
55
-
if err != nil {
56
-
return err
57
-
}
58
-
59
-
if err := putReferences(tx, c.AtUri(), c.References); err != nil {
60
-
return fmt.Errorf("put reference_links: %w", err)
61
-
}
62
-
63
-
return nil
64
-
}
65
-
66
-
func DeleteComments(e Execer, filters ...orm.Filter) error {
67
-
var conditions []string
68
-
var args []any
69
-
for _, filter := range filters {
70
-
conditions = append(conditions, filter.Condition())
71
-
args = append(args, filter.Arg()...)
72
-
}
73
-
74
-
whereClause := ""
75
-
if conditions != nil {
76
-
whereClause = " where " + strings.Join(conditions, " and ")
77
-
}
78
-
79
-
query := fmt.Sprintf(`update comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
80
-
81
-
_, err := e.Exec(query, args...)
82
-
return err
83
-
}
84
-
85
-
func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) {
86
-
commentMap := make(map[string]*models.Comment)
87
-
88
-
var conditions []string
89
-
var args []any
90
-
for _, filter := range filters {
91
-
conditions = append(conditions, filter.Condition())
92
-
args = append(args, filter.Arg()...)
93
-
}
94
-
95
-
whereClause := ""
96
-
if conditions != nil {
97
-
whereClause = " where " + strings.Join(conditions, " and ")
98
-
}
99
-
100
-
query := fmt.Sprintf(`
101
-
select
102
-
id,
103
-
did,
104
-
rkey,
105
-
subject_at,
106
-
reply_to,
107
-
body,
108
-
pull_submission_id,
109
-
created,
110
-
edited,
111
-
deleted
112
-
from
113
-
comments
114
-
%s
115
-
`, whereClause)
116
-
117
-
rows, err := e.Query(query, args...)
118
-
if err != nil {
119
-
return nil, err
120
-
}
121
-
122
-
for rows.Next() {
123
-
var comment models.Comment
124
-
var created string
125
-
var rkey, edited, deleted, replyTo sql.Null[string]
126
-
err := rows.Scan(
127
-
&comment.Id,
128
-
&comment.Did,
129
-
&rkey,
130
-
&comment.Subject,
131
-
&replyTo,
132
-
&comment.Body,
133
-
&comment.PullSubmissionId,
134
-
&created,
135
-
&edited,
136
-
&deleted,
137
-
)
138
-
if err != nil {
139
-
return nil, err
140
-
}
141
-
142
-
// this is a remnant from old times, newer comments always have rkey
143
-
if rkey.Valid {
144
-
comment.Rkey = rkey.V
145
-
}
146
-
147
-
if t, err := time.Parse(time.RFC3339, created); err == nil {
148
-
comment.Created = t
149
-
}
150
-
151
-
if edited.Valid {
152
-
if t, err := time.Parse(time.RFC3339, edited.V); err == nil {
153
-
comment.Edited = &t
154
-
}
155
-
}
156
-
157
-
if deleted.Valid {
158
-
if t, err := time.Parse(time.RFC3339, deleted.V); err == nil {
159
-
comment.Deleted = &t
160
-
}
161
-
}
162
-
163
-
if replyTo.Valid {
164
-
rt := syntax.ATURI(replyTo.V)
165
-
comment.ReplyTo = &rt
166
-
}
167
-
168
-
atUri := comment.AtUri().String()
169
-
commentMap[atUri] = &comment
170
-
}
171
-
172
-
if err := rows.Err(); err != nil {
173
-
return nil, err
174
-
}
175
-
defer rows.Close()
176
-
177
-
// collect references from each comments
178
-
commentAts := slices.Collect(maps.Keys(commentMap))
179
-
allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts))
180
-
if err != nil {
181
-
return nil, fmt.Errorf("failed to query reference_links: %w", err)
182
-
}
183
-
for commentAt, references := range allReferencs {
184
-
if comment, ok := commentMap[commentAt.String()]; ok {
185
-
comment.References = references
186
-
}
187
-
}
188
-
189
-
var comments []models.Comment
190
-
for _, c := range commentMap {
191
-
comments = append(comments, *c)
192
-
}
193
-
194
-
sort.Slice(comments, func(i, j int) bool {
195
-
return comments[i].Created.After(comments[j].Created)
196
-
})
197
-
198
-
return comments, nil
199
-
}
···
-81
appview/db/db.go
-81
appview/db/db.go
···
1173
return err
1174
})
1175
1176
-
orm.RunMigration(conn, logger, "add-comments-table", func(tx *sql.Tx) error {
1177
-
_, err := tx.Exec(`
1178
-
drop table if exists comments;
1179
-
1180
-
create table comments (
1181
-
-- identifiers
1182
-
id integer primary key autoincrement,
1183
-
did text not null,
1184
-
collection text not null default 'sh.tangled.comment',
1185
-
rkey text not null,
1186
-
at_uri text generated always as ('at://' || did || '/' || collection || '/' || rkey) stored,
1187
-
1188
-
-- at identifiers
1189
-
subject_at text not null,
1190
-
reply_to text, -- at_uri of parent comment
1191
-
1192
-
pull_submission_id integer, -- dirty fix until we atprotate the pull-rounds
1193
-
1194
-
-- content
1195
-
body text not null,
1196
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1197
-
edited text,
1198
-
deleted text,
1199
-
1200
-
-- constraints
1201
-
unique(did, rkey)
1202
-
);
1203
-
1204
-
insert into comments (
1205
-
did,
1206
-
collection,
1207
-
rkey,
1208
-
subject_at,
1209
-
reply_to,
1210
-
body,
1211
-
created,
1212
-
edited,
1213
-
deleted
1214
-
)
1215
-
select
1216
-
did,
1217
-
'sh.tangled.repo.issue.comment',
1218
-
rkey,
1219
-
issue_at,
1220
-
reply_to,
1221
-
body,
1222
-
created,
1223
-
edited,
1224
-
deleted
1225
-
from issue_comments
1226
-
where rkey is not null;
1227
-
1228
-
insert into comments (
1229
-
did,
1230
-
collection,
1231
-
rkey,
1232
-
subject_at,
1233
-
pull_submission_id,
1234
-
body,
1235
-
created
1236
-
)
1237
-
select
1238
-
c.owner_did,
1239
-
'sh.tangled.repo.pull.comment',
1240
-
substr(
1241
-
substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey
1242
-
instr(
1243
-
substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey
1244
-
'/'
1245
-
) + 1
1246
-
), -- rkey
1247
-
p.at_uri,
1248
-
c.submission_id,
1249
-
c.body,
1250
-
c.created
1251
-
from pull_comments c
1252
-
join pulls p on c.repo_at = p.repo_at and c.pull_id = p.pull_id;
1253
-
`)
1254
-
return err
1255
-
})
1256
-
1257
return &DB{
1258
db,
1259
logger,
+186
-6
appview/db/issues.go
+186
-6
appview/db/issues.go
···
100
}
101
102
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) {
103
-
issueMap := make(map[syntax.ATURI]*models.Issue) // at-uri -> issue
104
105
var conditions []string
106
var args []any
···
196
}
197
}
198
199
-
issueMap[issue.AtUri()] = &issue
200
}
201
202
// collect reverse repos
···
228
// collect comments
229
issueAts := slices.Collect(maps.Keys(issueMap))
230
231
-
comments, err := GetComments(e, orm.FilterIn("subject_at", issueAts))
232
if err != nil {
233
return nil, fmt.Errorf("failed to query comments: %w", err)
234
}
235
for i := range comments {
236
-
issueAt := comments[i].Subject
237
if issue, ok := issueMap[issueAt]; ok {
238
issue.Comments = append(issue.Comments, comments[i])
239
}
···
245
return nil, fmt.Errorf("failed to query labels: %w", err)
246
}
247
for issueAt, labels := range allLabels {
248
-
if issue, ok := issueMap[issueAt]; ok {
249
issue.Labels = labels
250
}
251
}
···
256
return nil, fmt.Errorf("failed to query reference_links: %w", err)
257
}
258
for issueAt, references := range allReferencs {
259
-
if issue, ok := issueMap[issueAt]; ok {
260
issue.References = references
261
}
262
}
···
348
}
349
350
return ids, nil
351
}
352
353
func DeleteIssues(tx *sql.Tx, did, rkey string) error {
···
100
}
101
102
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) {
103
+
issueMap := make(map[string]*models.Issue) // at-uri -> issue
104
105
var conditions []string
106
var args []any
···
196
}
197
}
198
199
+
atUri := issue.AtUri().String()
200
+
issueMap[atUri] = &issue
201
}
202
203
// collect reverse repos
···
229
// collect comments
230
issueAts := slices.Collect(maps.Keys(issueMap))
231
232
+
comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts))
233
if err != nil {
234
return nil, fmt.Errorf("failed to query comments: %w", err)
235
}
236
for i := range comments {
237
+
issueAt := comments[i].IssueAt
238
if issue, ok := issueMap[issueAt]; ok {
239
issue.Comments = append(issue.Comments, comments[i])
240
}
···
246
return nil, fmt.Errorf("failed to query labels: %w", err)
247
}
248
for issueAt, labels := range allLabels {
249
+
if issue, ok := issueMap[issueAt.String()]; ok {
250
issue.Labels = labels
251
}
252
}
···
257
return nil, fmt.Errorf("failed to query reference_links: %w", err)
258
}
259
for issueAt, references := range allReferencs {
260
+
if issue, ok := issueMap[issueAt.String()]; ok {
261
issue.References = references
262
}
263
}
···
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 (
357
+
did,
358
+
rkey,
359
+
issue_at,
360
+
body,
361
+
reply_to,
362
+
created,
363
+
edited
364
+
)
365
+
values (?, ?, ?, ?, ?, ?, null)
366
+
on conflict(did, rkey) do update set
367
+
issue_at = excluded.issue_at,
368
+
body = excluded.body,
369
+
edited = case
370
+
when
371
+
issue_comments.issue_at != excluded.issue_at
372
+
or issue_comments.body != excluded.body
373
+
or issue_comments.reply_to != excluded.reply_to
374
+
then ?
375
+
else issue_comments.edited
376
+
end`,
377
+
c.Did,
378
+
c.Rkey,
379
+
c.IssueAt,
380
+
c.Body,
381
+
c.ReplyTo,
382
+
c.Created.Format(time.RFC3339),
383
+
time.Now().Format(time.RFC3339),
384
+
)
385
+
if err != nil {
386
+
return 0, err
387
+
}
388
+
389
+
id, err := result.LastInsertId()
390
+
if err != nil {
391
+
return 0, err
392
+
}
393
+
394
+
if err := putReferences(tx, c.AtUri(), c.References); err != nil {
395
+
return 0, fmt.Errorf("put reference_links: %w", err)
396
+
}
397
+
398
+
return id, nil
399
+
}
400
+
401
+
func DeleteIssueComments(e Execer, filters ...orm.Filter) error {
402
+
var conditions []string
403
+
var args []any
404
+
for _, filter := range filters {
405
+
conditions = append(conditions, filter.Condition())
406
+
args = append(args, filter.Arg()...)
407
+
}
408
+
409
+
whereClause := ""
410
+
if conditions != nil {
411
+
whereClause = " where " + strings.Join(conditions, " and ")
412
+
}
413
+
414
+
query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
415
+
416
+
_, err := e.Exec(query, args...)
417
+
return err
418
+
}
419
+
420
+
func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) {
421
+
commentMap := make(map[string]*models.IssueComment)
422
+
423
+
var conditions []string
424
+
var args []any
425
+
for _, filter := range filters {
426
+
conditions = append(conditions, filter.Condition())
427
+
args = append(args, filter.Arg()...)
428
+
}
429
+
430
+
whereClause := ""
431
+
if conditions != nil {
432
+
whereClause = " where " + strings.Join(conditions, " and ")
433
+
}
434
+
435
+
query := fmt.Sprintf(`
436
+
select
437
+
id,
438
+
did,
439
+
rkey,
440
+
issue_at,
441
+
reply_to,
442
+
body,
443
+
created,
444
+
edited,
445
+
deleted
446
+
from
447
+
issue_comments
448
+
%s
449
+
`, whereClause)
450
+
451
+
rows, err := e.Query(query, args...)
452
+
if err != nil {
453
+
return nil, err
454
+
}
455
+
defer rows.Close()
456
+
457
+
for rows.Next() {
458
+
var comment models.IssueComment
459
+
var created string
460
+
var rkey, edited, deleted, replyTo sql.Null[string]
461
+
err := rows.Scan(
462
+
&comment.Id,
463
+
&comment.Did,
464
+
&rkey,
465
+
&comment.IssueAt,
466
+
&replyTo,
467
+
&comment.Body,
468
+
&created,
469
+
&edited,
470
+
&deleted,
471
+
)
472
+
if err != nil {
473
+
return nil, err
474
+
}
475
+
476
+
// this is a remnant from old times, newer comments always have rkey
477
+
if rkey.Valid {
478
+
comment.Rkey = rkey.V
479
+
}
480
+
481
+
if t, err := time.Parse(time.RFC3339, created); err == nil {
482
+
comment.Created = t
483
+
}
484
+
485
+
if edited.Valid {
486
+
if t, err := time.Parse(time.RFC3339, edited.V); err == nil {
487
+
comment.Edited = &t
488
+
}
489
+
}
490
+
491
+
if deleted.Valid {
492
+
if t, err := time.Parse(time.RFC3339, deleted.V); err == nil {
493
+
comment.Deleted = &t
494
+
}
495
+
}
496
+
497
+
if replyTo.Valid {
498
+
comment.ReplyTo = &replyTo.V
499
+
}
500
+
501
+
atUri := comment.AtUri().String()
502
+
commentMap[atUri] = &comment
503
+
}
504
+
505
+
if err = rows.Err(); err != nil {
506
+
return nil, err
507
+
}
508
+
509
+
// collect references for each comments
510
+
commentAts := slices.Collect(maps.Keys(commentMap))
511
+
allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts))
512
+
if err != nil {
513
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
514
+
}
515
+
for commentAt, references := range allReferencs {
516
+
if comment, ok := commentMap[commentAt.String()]; ok {
517
+
comment.References = references
518
+
}
519
+
}
520
+
521
+
var comments []models.IssueComment
522
+
for _, c := range commentMap {
523
+
comments = append(comments, *c)
524
+
}
525
+
526
+
sort.Slice(comments, func(i, j int) bool {
527
+
return comments[i].Created.After(comments[j].Created)
528
+
})
529
+
530
+
return comments, nil
531
}
532
533
func DeleteIssues(tx *sql.Tx, did, rkey string) error {
+121
-6
appview/db/pulls.go
+121
-6
appview/db/pulls.go
···
447
return nil, err
448
}
449
450
-
// Get comments for all submissions using GetComments
451
submissionIds := slices.Collect(maps.Keys(submissionMap))
452
-
comments, err := GetComments(e, orm.FilterIn("pull_submission_id", submissionIds))
453
if err != nil {
454
return nil, fmt.Errorf("failed to get pull comments: %w", err)
455
}
456
for _, comment := range comments {
457
-
if comment.PullSubmissionId != nil {
458
-
if submission, ok := submissionMap[*comment.PullSubmissionId]; ok {
459
-
submission.Comments = append(submission.Comments, comment)
460
-
}
461
}
462
}
463
···
477
return m, nil
478
}
479
480
// timeframe here is directly passed into the sql query filter, and any
481
// timeframe in the past should be negative; e.g.: "-3 months"
482
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) {
···
551
}
552
553
return pulls, nil
554
}
555
556
func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
···
447
return nil, err
448
}
449
450
+
// Get comments for all submissions using GetPullComments
451
submissionIds := slices.Collect(maps.Keys(submissionMap))
452
+
comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds))
453
if err != nil {
454
return nil, fmt.Errorf("failed to get pull comments: %w", err)
455
}
456
for _, comment := range comments {
457
+
if submission, ok := submissionMap[comment.SubmissionId]; ok {
458
+
submission.Comments = append(submission.Comments, comment)
459
}
460
}
461
···
475
return m, nil
476
}
477
478
+
func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) {
479
+
var conditions []string
480
+
var args []any
481
+
for _, filter := range filters {
482
+
conditions = append(conditions, filter.Condition())
483
+
args = append(args, filter.Arg()...)
484
+
}
485
+
486
+
whereClause := ""
487
+
if conditions != nil {
488
+
whereClause = " where " + strings.Join(conditions, " and ")
489
+
}
490
+
491
+
query := fmt.Sprintf(`
492
+
select
493
+
id,
494
+
pull_id,
495
+
submission_id,
496
+
repo_at,
497
+
owner_did,
498
+
comment_at,
499
+
body,
500
+
created
501
+
from
502
+
pull_comments
503
+
%s
504
+
order by
505
+
created asc
506
+
`, whereClause)
507
+
508
+
rows, err := e.Query(query, args...)
509
+
if err != nil {
510
+
return nil, err
511
+
}
512
+
defer rows.Close()
513
+
514
+
commentMap := make(map[string]*models.PullComment)
515
+
for rows.Next() {
516
+
var comment models.PullComment
517
+
var createdAt string
518
+
err := rows.Scan(
519
+
&comment.ID,
520
+
&comment.PullId,
521
+
&comment.SubmissionId,
522
+
&comment.RepoAt,
523
+
&comment.OwnerDid,
524
+
&comment.CommentAt,
525
+
&comment.Body,
526
+
&createdAt,
527
+
)
528
+
if err != nil {
529
+
return nil, err
530
+
}
531
+
532
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
533
+
comment.Created = t
534
+
}
535
+
536
+
atUri := comment.AtUri().String()
537
+
commentMap[atUri] = &comment
538
+
}
539
+
540
+
if err := rows.Err(); err != nil {
541
+
return nil, err
542
+
}
543
+
544
+
// collect references for each comments
545
+
commentAts := slices.Collect(maps.Keys(commentMap))
546
+
allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts))
547
+
if err != nil {
548
+
return nil, fmt.Errorf("failed to query reference_links: %w", err)
549
+
}
550
+
for commentAt, references := range allReferencs {
551
+
if comment, ok := commentMap[commentAt.String()]; ok {
552
+
comment.References = references
553
+
}
554
+
}
555
+
556
+
var comments []models.PullComment
557
+
for _, c := range commentMap {
558
+
comments = append(comments, *c)
559
+
}
560
+
561
+
sort.Slice(comments, func(i, j int) bool {
562
+
return comments[i].Created.Before(comments[j].Created)
563
+
})
564
+
565
+
return comments, nil
566
+
}
567
+
568
// timeframe here is directly passed into the sql query filter, and any
569
// timeframe in the past should be negative; e.g.: "-3 months"
570
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) {
···
639
}
640
641
return pulls, nil
642
+
}
643
+
644
+
func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) {
645
+
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
646
+
res, err := tx.Exec(
647
+
query,
648
+
comment.OwnerDid,
649
+
comment.RepoAt,
650
+
comment.SubmissionId,
651
+
comment.CommentAt,
652
+
comment.PullId,
653
+
comment.Body,
654
+
)
655
+
if err != nil {
656
+
return 0, err
657
+
}
658
+
659
+
i, err := res.LastInsertId()
660
+
if err != nil {
661
+
return 0, err
662
+
}
663
+
664
+
if err := putReferences(tx, comment.AtUri(), comment.References); err != nil {
665
+
return 0, fmt.Errorf("put reference_links: %w", err)
666
+
}
667
+
668
+
return i, nil
669
}
670
671
func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
+32
-20
appview/db/reference.go
+32
-20
appview/db/reference.go
···
11
"tangled.org/core/orm"
12
)
13
14
-
// ValidateReferenceLinks resolves refLinks to Issue/PR/Comment ATURIs.
15
// It will ignore missing refLinks.
16
func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
17
var (
···
53
values %s
54
)
55
select
56
-
i.at_uri, c.at_uri
57
from input inp
58
join repos r
59
on r.did = inp.owner_did
···
61
join issues i
62
on i.repo_at = r.at_uri
63
and i.issue_id = inp.issue_id
64
-
left join comments c
65
on inp.comment_id is not null
66
-
and c.subject_at = i.at_uri
67
and c.id = inp.comment_id
68
`,
69
strings.Join(vals, ","),
···
78
79
for rows.Next() {
80
// Scan rows
81
-
var issueUri string
82
-
var commentUri sql.NullString
83
var uri syntax.ATURI
84
-
if err := rows.Scan(&issueUri, &commentUri); err != nil {
85
return nil, err
86
}
87
-
if commentUri.Valid {
88
-
uri = syntax.ATURI(commentUri.String)
89
} else {
90
-
uri = syntax.ATURI(issueUri)
91
}
92
uris = append(uris, uri)
93
}
···
113
values %s
114
)
115
select
116
-
p.owner_did, p.rkey, c.at_uri
117
from input inp
118
join repos r
119
on r.did = inp.owner_did
···
121
join pulls p
122
on p.repo_at = r.at_uri
123
and p.pull_id = inp.pull_id
124
-
left join comments c
125
on inp.comment_id is not null
126
-
and c.subject_at = ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey)
127
and c.id = inp.comment_id
128
`,
129
strings.Join(vals, ","),
···
271
return nil, fmt.Errorf("get issue backlinks: %w", err)
272
}
273
backlinks = append(backlinks, ls...)
274
-
ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.CommentNSID])
275
if err != nil {
276
return nil, fmt.Errorf("get issue_comment backlinks: %w", err)
277
}
···
281
return nil, fmt.Errorf("get pull backlinks: %w", err)
282
}
283
backlinks = append(backlinks, ls...)
284
-
ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.CommentNSID])
285
if err != nil {
286
return nil, fmt.Errorf("get pull_comment backlinks: %w", err)
287
}
···
340
rows, err := e.Query(
341
fmt.Sprintf(
342
`select r.did, r.name, i.issue_id, c.id, i.title, i.open
343
-
from comments c
344
join issues i
345
-
on i.at_uri = c.subject_at
346
join repos r
347
on r.at_uri = i.repo_at
348
where %s`,
···
416
if len(aturis) == 0 {
417
return nil, nil
418
}
419
-
filter := orm.FilterIn("c.at_uri", aturis)
420
rows, err := e.Query(
421
fmt.Sprintf(
422
`select r.did, r.name, p.pull_id, c.id, p.title, p.state
423
from repos r
424
join pulls p
425
on r.at_uri = p.repo_at
426
-
join comments c
427
-
on ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) = c.subject_at
428
where %s`,
429
filter.Condition(),
430
),
···
11
"tangled.org/core/orm"
12
)
13
14
+
// ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs.
15
// It will ignore missing refLinks.
16
func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
17
var (
···
53
values %s
54
)
55
select
56
+
i.did, i.rkey,
57
+
c.did, c.rkey
58
from input inp
59
join repos r
60
on r.did = inp.owner_did
···
62
join issues i
63
on i.repo_at = r.at_uri
64
and i.issue_id = inp.issue_id
65
+
left join issue_comments c
66
on inp.comment_id is not null
67
+
and c.issue_at = i.at_uri
68
and c.id = inp.comment_id
69
`,
70
strings.Join(vals, ","),
···
79
80
for rows.Next() {
81
// Scan rows
82
+
var issueOwner, issueRkey string
83
+
var commentOwner, commentRkey sql.NullString
84
var uri syntax.ATURI
85
+
if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil {
86
return nil, err
87
}
88
+
if commentOwner.Valid && commentRkey.Valid {
89
+
uri = syntax.ATURI(fmt.Sprintf(
90
+
"at://%s/%s/%s",
91
+
commentOwner.String,
92
+
tangled.RepoIssueCommentNSID,
93
+
commentRkey.String,
94
+
))
95
} else {
96
+
uri = syntax.ATURI(fmt.Sprintf(
97
+
"at://%s/%s/%s",
98
+
issueOwner,
99
+
tangled.RepoIssueNSID,
100
+
issueRkey,
101
+
))
102
}
103
uris = append(uris, uri)
104
}
···
124
values %s
125
)
126
select
127
+
p.owner_did, p.rkey,
128
+
c.comment_at
129
from input inp
130
join repos r
131
on r.did = inp.owner_did
···
133
join pulls p
134
on p.repo_at = r.at_uri
135
and p.pull_id = inp.pull_id
136
+
left join pull_comments c
137
on inp.comment_id is not null
138
+
and c.repo_at = r.at_uri and c.pull_id = p.pull_id
139
and c.id = inp.comment_id
140
`,
141
strings.Join(vals, ","),
···
283
return nil, fmt.Errorf("get issue backlinks: %w", err)
284
}
285
backlinks = append(backlinks, ls...)
286
+
ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID])
287
if err != nil {
288
return nil, fmt.Errorf("get issue_comment backlinks: %w", err)
289
}
···
293
return nil, fmt.Errorf("get pull backlinks: %w", err)
294
}
295
backlinks = append(backlinks, ls...)
296
+
ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID])
297
if err != nil {
298
return nil, fmt.Errorf("get pull_comment backlinks: %w", err)
299
}
···
352
rows, err := e.Query(
353
fmt.Sprintf(
354
`select r.did, r.name, i.issue_id, c.id, i.title, i.open
355
+
from issue_comments c
356
join issues i
357
+
on i.at_uri = c.issue_at
358
join repos r
359
on r.at_uri = i.repo_at
360
where %s`,
···
428
if len(aturis) == 0 {
429
return nil, nil
430
}
431
+
filter := orm.FilterIn("c.comment_at", aturis)
432
rows, err := e.Query(
433
fmt.Sprintf(
434
`select r.did, r.name, p.pull_id, c.id, p.title, p.state
435
from repos r
436
join pulls p
437
on r.at_uri = p.repo_at
438
+
join pull_comments c
439
+
on r.at_uri = c.repo_at and p.pull_id = c.pull_id
440
where %s`,
441
filter.Condition(),
442
),
+11
-19
appview/ingester.go
+11
-19
appview/ingester.go
···
79
err = i.ingestString(e)
80
case tangled.RepoIssueNSID:
81
err = i.ingestIssue(ctx, e)
82
-
case tangled.CommentNSID:
83
-
err = i.ingestComment(e)
84
case tangled.LabelDefinitionNSID:
85
err = i.ingestLabelDefinition(e)
86
case tangled.LabelOpNSID:
···
868
return nil
869
}
870
871
-
func (i *Ingester) ingestComment(e *jmodels.Event) error {
872
did := e.Did
873
rkey := e.Commit.RKey
874
875
var err error
876
877
-
l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
878
l.Info("ingesting record")
879
880
ddb, ok := i.Db.Execer.(*db.DB)
···
885
switch e.Commit.Operation {
886
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
887
raw := json.RawMessage(e.Commit.Record)
888
-
record := tangled.Comment{}
889
err = json.Unmarshal(raw, &record)
890
if err != nil {
891
return fmt.Errorf("invalid record: %w", err)
892
}
893
894
-
comment, err := models.CommentFromRecord(did, rkey, record)
895
if err != nil {
896
return fmt.Errorf("failed to parse comment from record: %w", err)
897
}
898
899
-
// TODO: ingest pull comments
900
-
// we aren't ingesting pull comments yet because pull itself isn't fully atprotated.
901
-
// so we cannot know which round this comment is pointing to
902
-
if comment.Subject.Collection().String() == tangled.RepoPullNSID {
903
-
l.Info("skip ingesting pull comments")
904
-
return nil
905
-
}
906
-
907
-
if err := comment.Validate(); err != nil {
908
return fmt.Errorf("failed to validate comment: %w", err)
909
}
910
···
914
}
915
defer tx.Rollback()
916
917
-
err = db.PutComment(tx, comment)
918
if err != nil {
919
-
return fmt.Errorf("failed to create comment: %w", err)
920
}
921
922
return tx.Commit()
923
924
case jmodels.CommitOperationDelete:
925
-
if err := db.DeleteComments(
926
ddb,
927
orm.FilterEq("did", did),
928
orm.FilterEq("rkey", rkey),
929
); err != nil {
930
-
return fmt.Errorf("failed to delete comment record: %w", err)
931
}
932
933
return nil
···
79
err = i.ingestString(e)
80
case tangled.RepoIssueNSID:
81
err = i.ingestIssue(ctx, e)
82
+
case tangled.RepoIssueCommentNSID:
83
+
err = i.ingestIssueComment(e)
84
case tangled.LabelDefinitionNSID:
85
err = i.ingestLabelDefinition(e)
86
case tangled.LabelOpNSID:
···
868
return nil
869
}
870
871
+
func (i *Ingester) ingestIssueComment(e *jmodels.Event) error {
872
did := e.Did
873
rkey := e.Commit.RKey
874
875
var err error
876
877
+
l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
878
l.Info("ingesting record")
879
880
ddb, ok := i.Db.Execer.(*db.DB)
···
885
switch e.Commit.Operation {
886
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
887
raw := json.RawMessage(e.Commit.Record)
888
+
record := tangled.RepoIssueComment{}
889
err = json.Unmarshal(raw, &record)
890
if err != nil {
891
return fmt.Errorf("invalid record: %w", err)
892
}
893
894
+
comment, err := models.IssueCommentFromRecord(did, rkey, record)
895
if err != nil {
896
return fmt.Errorf("failed to parse comment from record: %w", err)
897
}
898
899
+
if err := i.Validator.ValidateIssueComment(comment); err != nil {
900
return fmt.Errorf("failed to validate comment: %w", err)
901
}
902
···
906
}
907
defer tx.Rollback()
908
909
+
_, err = db.AddIssueComment(tx, *comment)
910
if err != nil {
911
+
return fmt.Errorf("failed to create issue comment: %w", err)
912
}
913
914
return tx.Commit()
915
916
case jmodels.CommitOperationDelete:
917
+
if err := db.DeleteIssueComments(
918
ddb,
919
orm.FilterEq("did", did),
920
orm.FilterEq("rkey", rkey),
921
); err != nil {
922
+
return fmt.Errorf("failed to delete issue comment record: %w", err)
923
}
924
925
return nil
+29
-31
appview/issues/issues.go
+29
-31
appview/issues/issues.go
···
403
404
body := r.FormValue("body")
405
if body == "" {
406
-
rp.pages.Notice(w, "issue-comment", "Body is required")
407
return
408
}
409
410
-
var replyTo *syntax.ATURI
411
-
replyToRaw := r.FormValue("reply-to")
412
-
if replyToRaw != "" {
413
-
aturi, err := syntax.ParseATURI(r.FormValue("reply-to"))
414
-
if err != nil {
415
-
rp.pages.Notice(w, "issue-comment", "reply-to should be valid AT-URI")
416
-
return
417
-
}
418
-
replyTo = &aturi
419
}
420
421
mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
422
423
-
comment := models.Comment{
424
-
Did: syntax.DID(user.Did),
425
Rkey: tid.TID(),
426
-
Subject: issue.AtUri(),
427
ReplyTo: replyTo,
428
Body: body,
429
Created: time.Now(),
430
Mentions: mentions,
431
References: references,
432
}
433
-
if err = comment.Validate(); err != nil {
434
l.Error("failed to validate comment", "err", err)
435
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
436
return
···
446
447
// create a record first
448
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
449
-
Collection: tangled.CommentNSID,
450
-
Repo: user.Did,
451
Rkey: comment.Rkey,
452
Record: &lexutil.LexiconTypeDecoder{
453
Val: &record,
···
473
}
474
defer tx.Rollback()
475
476
-
err = db.PutComment(tx, &comment)
477
if err != nil {
478
l.Error("failed to create comment", "err", err)
479
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
···
489
// reset atUri to make rollback a no-op
490
atUri = ""
491
492
-
rp.notifier.NewComment(r.Context(), &comment)
493
494
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
495
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id))
496
}
497
498
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
···
507
}
508
509
commentId := chi.URLParam(r, "commentId")
510
-
comments, err := db.GetComments(
511
rp.db,
512
orm.FilterEq("id", commentId),
513
)
···
543
}
544
545
commentId := chi.URLParam(r, "commentId")
546
-
comments, err := db.GetComments(
547
rp.db,
548
orm.FilterEq("id", commentId),
549
)
···
559
}
560
comment := comments[0]
561
562
-
if comment.Did.String() != user.Did {
563
l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did)
564
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
565
return
···
599
}
600
defer tx.Rollback()
601
602
-
err = db.PutComment(tx, &newComment)
603
if err != nil {
604
l.Error("failed to perferom update-description query", "err", err)
605
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
···
610
// rkey is optional, it was introduced later
611
if newComment.Rkey != "" {
612
// update the record on pds
613
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.CommentNSID, user.Did, comment.Rkey)
614
if err != nil {
615
l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
616
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
···
618
}
619
620
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
621
-
Collection: tangled.CommentNSID,
622
Repo: user.Did,
623
Rkey: newComment.Rkey,
624
SwapRecord: ex.Cid,
···
653
}
654
655
commentId := chi.URLParam(r, "commentId")
656
-
comments, err := db.GetComments(
657
rp.db,
658
orm.FilterEq("id", commentId),
659
)
···
689
}
690
691
commentId := chi.URLParam(r, "commentId")
692
-
comments, err := db.GetComments(
693
rp.db,
694
orm.FilterEq("id", commentId),
695
)
···
725
}
726
727
commentId := chi.URLParam(r, "commentId")
728
-
comments, err := db.GetComments(
729
rp.db,
730
orm.FilterEq("id", commentId),
731
)
···
741
}
742
comment := comments[0]
743
744
-
if comment.Did.String() != user.Did {
745
l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
746
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
747
return
···
754
755
// optimistic deletion
756
deleted := time.Now()
757
-
err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id))
758
if err != nil {
759
l.Error("failed to delete comment", "err", err)
760
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
···
770
return
771
}
772
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
773
-
Collection: tangled.CommentNSID,
774
Repo: user.Did,
775
Rkey: comment.Rkey,
776
})
···
403
404
body := r.FormValue("body")
405
if body == "" {
406
+
rp.pages.Notice(w, "issue", "Body is required")
407
return
408
}
409
410
+
replyToUri := r.FormValue("reply-to")
411
+
var replyTo *string
412
+
if replyToUri != "" {
413
+
replyTo = &replyToUri
414
}
415
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,
423
Body: body,
424
Created: time.Now(),
425
Mentions: mentions,
426
References: references,
427
}
428
+
if err = rp.validator.ValidateIssueComment(&comment); err != nil {
429
l.Error("failed to validate comment", "err", err)
430
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
431
return
···
441
442
// create a record first
443
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
444
+
Collection: tangled.RepoIssueCommentNSID,
445
+
Repo: comment.Did,
446
Rkey: comment.Rkey,
447
Record: &lexutil.LexiconTypeDecoder{
448
Val: &record,
···
468
}
469
defer tx.Rollback()
470
471
+
commentId, err := db.AddIssueComment(tx, comment)
472
if err != nil {
473
l.Error("failed to create comment", "err", err)
474
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
···
484
// reset atUri to make rollback a no-op
485
atUri = ""
486
487
+
// notify about the new comment
488
+
comment.Id = commentId
489
+
490
+
rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
491
492
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
493
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId))
494
}
495
496
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
···
505
}
506
507
commentId := chi.URLParam(r, "commentId")
508
+
comments, err := db.GetIssueComments(
509
rp.db,
510
orm.FilterEq("id", commentId),
511
)
···
541
}
542
543
commentId := chi.URLParam(r, "commentId")
544
+
comments, err := db.GetIssueComments(
545
rp.db,
546
orm.FilterEq("id", commentId),
547
)
···
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
···
597
}
598
defer tx.Rollback()
599
600
+
_, err = db.AddIssueComment(tx, newComment)
601
if err != nil {
602
l.Error("failed to perferom update-description query", "err", err)
603
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
···
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.")
···
616
}
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,
···
651
}
652
653
commentId := chi.URLParam(r, "commentId")
654
+
comments, err := db.GetIssueComments(
655
rp.db,
656
orm.FilterEq("id", commentId),
657
)
···
687
}
688
689
commentId := chi.URLParam(r, "commentId")
690
+
comments, err := db.GetIssueComments(
691
rp.db,
692
orm.FilterEq("id", commentId),
693
)
···
723
}
724
725
commentId := chi.URLParam(r, "commentId")
726
+
comments, err := db.GetIssueComments(
727
rp.db,
728
orm.FilterEq("id", commentId),
729
)
···
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
···
752
753
// optimistic deletion
754
deleted := time.Now()
755
+
err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id))
756
if err != nil {
757
l.Error("failed to delete comment", "err", err)
758
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
···
768
return
769
}
770
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
771
+
Collection: tangled.RepoIssueCommentNSID,
772
Repo: user.Did,
773
Rkey: comment.Rkey,
774
})
+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
-117
appview/models/comment.go
-117
appview/models/comment.go
···
1
-
package models
2
-
3
-
import (
4
-
"fmt"
5
-
"strings"
6
-
"time"
7
-
8
-
"github.com/bluesky-social/indigo/atproto/syntax"
9
-
"tangled.org/core/api/tangled"
10
-
)
11
-
12
-
type Comment struct {
13
-
Id int64
14
-
Did syntax.DID
15
-
Rkey string
16
-
Subject syntax.ATURI
17
-
ReplyTo *syntax.ATURI
18
-
Body string
19
-
Created time.Time
20
-
Edited *time.Time
21
-
Deleted *time.Time
22
-
Mentions []syntax.DID
23
-
References []syntax.ATURI
24
-
PullSubmissionId *int
25
-
}
26
-
27
-
func (c *Comment) AtUri() syntax.ATURI {
28
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, tangled.CommentNSID, c.Rkey))
29
-
}
30
-
31
-
func (c *Comment) AsRecord() tangled.Comment {
32
-
mentions := make([]string, len(c.Mentions))
33
-
for i, did := range c.Mentions {
34
-
mentions[i] = string(did)
35
-
}
36
-
references := make([]string, len(c.References))
37
-
for i, uri := range c.References {
38
-
references[i] = string(uri)
39
-
}
40
-
var replyTo *string
41
-
if c.ReplyTo != nil {
42
-
replyToStr := c.ReplyTo.String()
43
-
replyTo = &replyToStr
44
-
}
45
-
return tangled.Comment{
46
-
Subject: c.Subject.String(),
47
-
Body: c.Body,
48
-
CreatedAt: c.Created.Format(time.RFC3339),
49
-
ReplyTo: replyTo,
50
-
Mentions: mentions,
51
-
References: references,
52
-
}
53
-
}
54
-
55
-
func (c *Comment) IsTopLevel() bool {
56
-
return c.ReplyTo == nil
57
-
}
58
-
59
-
func (c *Comment) IsReply() bool {
60
-
return c.ReplyTo != nil
61
-
}
62
-
63
-
func (c *Comment) Validate() error {
64
-
// TODO: sanitize the body and then trim space
65
-
if sb := strings.TrimSpace(c.Body); sb == "" {
66
-
return fmt.Errorf("body is empty after HTML sanitization")
67
-
}
68
-
69
-
// if it's for PR, PullSubmissionId should not be nil
70
-
if c.Subject.Collection().String() == tangled.RepoPullNSID {
71
-
if c.PullSubmissionId == nil {
72
-
return fmt.Errorf("PullSubmissionId should not be nil")
73
-
}
74
-
}
75
-
return nil
76
-
}
77
-
78
-
func CommentFromRecord(did, rkey string, record tangled.Comment) (*Comment, error) {
79
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
80
-
if err != nil {
81
-
created = time.Now()
82
-
}
83
-
84
-
ownerDid := did
85
-
86
-
if _, err = syntax.ParseATURI(record.Subject); err != nil {
87
-
return nil, err
88
-
}
89
-
90
-
i := record
91
-
mentions := make([]syntax.DID, len(record.Mentions))
92
-
for i, did := range record.Mentions {
93
-
mentions[i] = syntax.DID(did)
94
-
}
95
-
references := make([]syntax.ATURI, len(record.References))
96
-
for i, uri := range i.References {
97
-
references[i] = syntax.ATURI(uri)
98
-
}
99
-
var replyTo *syntax.ATURI
100
-
if record.ReplyTo != nil {
101
-
replyToAtUri := syntax.ATURI(*record.ReplyTo)
102
-
replyTo = &replyToAtUri
103
-
}
104
-
105
-
comment := Comment{
106
-
Did: syntax.DID(ownerDid),
107
-
Rkey: rkey,
108
-
Body: record.Body,
109
-
Subject: syntax.ATURI(record.Subject),
110
-
ReplyTo: replyTo,
111
-
Created: created,
112
-
Mentions: mentions,
113
-
References: references,
114
-
}
115
-
116
-
return &comment, nil
117
-
}
···
+89
-8
appview/models/issue.go
+89
-8
appview/models/issue.go
···
26
27
// optionally, populate this when querying for reverse mappings
28
// like comment counts, parent repo etc.
29
-
Comments []Comment
30
Labels LabelState
31
Repo *Repo
32
}
···
62
}
63
64
type CommentListItem struct {
65
-
Self *Comment
66
-
Replies []*Comment
67
}
68
69
func (it *CommentListItem) Participants() []syntax.DID {
···
88
89
func (i *Issue) CommentList() []CommentListItem {
90
// Create a map to quickly find comments by their aturi
91
-
toplevel := make(map[syntax.ATURI]*CommentListItem)
92
-
var replies []*Comment
93
94
// collect top level comments into the map
95
for _, comment := range i.Comments {
96
if comment.IsTopLevel() {
97
-
toplevel[comment.AtUri()] = &CommentListItem{
98
Self: &comment,
99
}
100
} else {
···
115
}
116
117
// sort everything
118
-
sortFunc := func(a, b *Comment) bool {
119
return a.Created.Before(b.Created)
120
}
121
sort.Slice(listing, func(i, j int) bool {
···
144
addParticipant(i.Did)
145
146
for _, c := range i.Comments {
147
-
addParticipant(c.Did.String())
148
}
149
150
return participants
···
171
Open: true, // new issues are open by default
172
}
173
}
···
26
27
// optionally, populate this when querying for reverse mappings
28
// like comment counts, parent repo etc.
29
+
Comments []IssueComment
30
Labels LabelState
31
Repo *Repo
32
}
···
62
}
63
64
type CommentListItem struct {
65
+
Self *IssueComment
66
+
Replies []*IssueComment
67
}
68
69
func (it *CommentListItem) Participants() []syntax.DID {
···
88
89
func (i *Issue) CommentList() []CommentListItem {
90
// Create a map to quickly find comments by their aturi
91
+
toplevel := make(map[string]*CommentListItem)
92
+
var replies []*IssueComment
93
94
// collect top level comments into the map
95
for _, comment := range i.Comments {
96
if comment.IsTopLevel() {
97
+
toplevel[comment.AtUri().String()] = &CommentListItem{
98
Self: &comment,
99
}
100
} else {
···
115
}
116
117
// sort everything
118
+
sortFunc := func(a, b *IssueComment) bool {
119
return a.Created.Before(b.Created)
120
}
121
sort.Slice(listing, func(i, j int) bool {
···
144
addParticipant(i.Did)
145
146
for _, c := range i.Comments {
147
+
addParticipant(c.Did)
148
}
149
150
return participants
···
171
Open: true, // new issues are open by default
172
}
173
}
174
+
175
+
type IssueComment struct {
176
+
Id int64
177
+
Did string
178
+
Rkey string
179
+
IssueAt string
180
+
ReplyTo *string
181
+
Body string
182
+
Created time.Time
183
+
Edited *time.Time
184
+
Deleted *time.Time
185
+
Mentions []syntax.DID
186
+
References []syntax.ATURI
187
+
}
188
+
189
+
func (i *IssueComment) AtUri() syntax.ATURI {
190
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
191
+
}
192
+
193
+
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
194
+
mentions := make([]string, len(i.Mentions))
195
+
for i, did := range i.Mentions {
196
+
mentions[i] = string(did)
197
+
}
198
+
references := make([]string, len(i.References))
199
+
for i, uri := range i.References {
200
+
references[i] = string(uri)
201
+
}
202
+
return tangled.RepoIssueComment{
203
+
Body: i.Body,
204
+
Issue: i.IssueAt,
205
+
CreatedAt: i.Created.Format(time.RFC3339),
206
+
ReplyTo: i.ReplyTo,
207
+
Mentions: mentions,
208
+
References: references,
209
+
}
210
+
}
211
+
212
+
func (i *IssueComment) IsTopLevel() bool {
213
+
return i.ReplyTo == nil
214
+
}
215
+
216
+
func (i *IssueComment) IsReply() bool {
217
+
return i.ReplyTo != nil
218
+
}
219
+
220
+
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
221
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
222
+
if err != nil {
223
+
created = time.Now()
224
+
}
225
+
226
+
ownerDid := did
227
+
228
+
if _, err = syntax.ParseATURI(record.Issue); err != nil {
229
+
return nil, err
230
+
}
231
+
232
+
i := record
233
+
mentions := make([]syntax.DID, len(record.Mentions))
234
+
for i, did := range record.Mentions {
235
+
mentions[i] = syntax.DID(did)
236
+
}
237
+
references := make([]syntax.ATURI, len(record.References))
238
+
for i, uri := range i.References {
239
+
references[i] = syntax.ATURI(uri)
240
+
}
241
+
242
+
comment := IssueComment{
243
+
Did: ownerDid,
244
+
Rkey: rkey,
245
+
Body: record.Body,
246
+
IssueAt: record.Issue,
247
+
ReplyTo: record.ReplyTo,
248
+
Created: created,
249
+
Mentions: mentions,
250
+
References: references,
251
+
}
252
+
253
+
return &comment, nil
254
+
}
+39
appview/models/pipeline.go
+39
appview/models/pipeline.go
···
1
package models
2
3
import (
4
+
"fmt"
5
"slices"
6
+
"strings"
7
"time"
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
···
52
}
53
54
return 0
55
+
}
56
+
57
+
// produces short summary of successes:
58
+
// - "0/4" when zero successes of 4 workflows
59
+
// - "4/4" when all successes of 4 workflows
60
+
// - "0/0" when no workflows run in this pipeline
61
+
func (p Pipeline) ShortStatusSummary() string {
62
+
counts := make(map[spindle.StatusKind]int)
63
+
for _, w := range p.Statuses {
64
+
counts[w.Latest().Status] += 1
65
+
}
66
+
67
+
total := len(p.Statuses)
68
+
successes := counts[spindle.StatusKindSuccess]
69
+
70
+
return fmt.Sprintf("%d/%d", successes, total)
71
+
}
72
+
73
+
// produces a string of the form "3/4 success, 2/4 failed, 1/4 pending"
74
+
func (p Pipeline) LongStatusSummary() string {
75
+
counts := make(map[spindle.StatusKind]int)
76
+
for _, w := range p.Statuses {
77
+
counts[w.Latest().Status] += 1
78
+
}
79
+
80
+
total := len(p.Statuses)
81
+
82
+
var result []string
83
+
// finish states first, followed by start states
84
+
states := append(spindle.FinishStates[:], spindle.StartStates[:]...)
85
+
for _, state := range states {
86
+
if count, ok := counts[state]; ok {
87
+
result = append(result, fmt.Sprintf("%d/%d %s", count, total, state.String()))
88
+
}
89
+
}
90
+
91
+
return strings.Join(result, ", ")
92
}
93
94
func (p Pipeline) Counts() map[string]int {
+46
-2
appview/models/pull.go
+46
-2
appview/models/pull.go
···
138
RoundNumber int
139
Patch string
140
Combined string
141
-
Comments []Comment
142
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
143
144
// meta
145
Created time.Time
146
}
147
148
func (p *Pull) LastRoundNumber() int {
149
return len(p.Submissions) - 1
···
245
addParticipant(s.PullAt.Authority().String())
246
247
for _, c := range s.Comments {
248
-
addParticipant(c.Did.String())
249
}
250
251
return participants
···
138
RoundNumber int
139
Patch string
140
Combined string
141
+
Comments []PullComment
142
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
143
144
// meta
145
Created time.Time
146
}
147
+
148
+
type PullComment struct {
149
+
// ids
150
+
ID int
151
+
PullId int
152
+
SubmissionId int
153
+
154
+
// at ids
155
+
RepoAt string
156
+
OwnerDid string
157
+
CommentAt string
158
+
159
+
// content
160
+
Body string
161
+
162
+
// meta
163
+
Mentions []syntax.DID
164
+
References []syntax.ATURI
165
+
166
+
// meta
167
+
Created time.Time
168
+
}
169
+
170
+
func (p *PullComment) AtUri() syntax.ATURI {
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
···
289
addParticipant(s.PullAt.Authority().String())
290
291
for _, c := range s.Comments {
292
+
addParticipant(c.OwnerDid)
293
}
294
295
return participants
+113
-111
appview/notify/db/db.go
+113
-111
appview/notify/db/db.go
···
74
// no-op
75
}
76
77
-
func (n *databaseNotifier) NewComment(ctx context.Context, comment *models.Comment) {
78
-
var (
79
-
// built the recipients list:
80
-
// - the owner of the repo
81
-
// - | if the comment is a reply -> everybody on that thread
82
-
// | if the comment is a top level -> just the issue owner
83
-
// - remove mentioned users from the recipients list
84
-
recipients = sets.New[syntax.DID]()
85
-
entityType string
86
-
entityId string
87
-
repoId *int64
88
-
issueId *int64
89
-
pullId *int64
90
-
)
91
-
92
-
subjectDid, err := comment.Subject.Authority().AsDID()
93
if err != nil {
94
-
log.Printf("NewComment: expected did based at-uri for comment.subject")
95
return
96
}
97
-
switch comment.Subject.Collection() {
98
-
case tangled.RepoIssueNSID:
99
-
issues, err := db.GetIssues(
100
-
n.db,
101
-
orm.FilterEq("did", subjectDid),
102
-
orm.FilterEq("rkey", comment.Subject.RecordKey()),
103
-
)
104
-
if err != nil {
105
-
log.Printf("NewComment: failed to get issues: %v", err)
106
-
return
107
-
}
108
-
if len(issues) == 0 {
109
-
log.Printf("NewComment: no issue found for %s", comment.Subject)
110
-
return
111
-
}
112
-
issue := issues[0]
113
114
-
recipients.Insert(syntax.DID(issue.Repo.Did))
115
-
if comment.IsReply() {
116
-
// if this comment is a reply, then notify everybody in that thread
117
-
parentAtUri := *comment.ReplyTo
118
-
119
-
// find the parent thread, and add all DIDs from here to the recipient list
120
-
for _, t := range issue.CommentList() {
121
-
if t.Self.AtUri() == parentAtUri {
122
-
for _, p := range t.Participants() {
123
-
recipients.Insert(p)
124
-
}
125
-
}
126
-
}
127
-
} else {
128
-
// not a reply, notify just the issue author
129
-
recipients.Insert(syntax.DID(issue.Did))
130
-
}
131
-
132
-
entityType = "issue"
133
-
entityId = issue.AtUri().String()
134
-
repoId = &issue.Repo.Id
135
-
issueId = &issue.Id
136
-
case tangled.RepoPullNSID:
137
-
pulls, err := db.GetPullsWithLimit(
138
-
n.db,
139
-
1,
140
-
orm.FilterEq("owner_did", subjectDid),
141
-
orm.FilterEq("rkey", comment.Subject.RecordKey()),
142
-
)
143
-
if err != nil {
144
-
log.Printf("NewComment: failed to get pulls: %v", err)
145
-
return
146
-
}
147
-
if len(pulls) == 0 {
148
-
log.Printf("NewComment: no pull found for %s", comment.Subject)
149
-
return
150
-
}
151
-
pull := pulls[0]
152
-
153
-
pull.Repo, err = db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt))
154
-
if err != nil {
155
-
log.Printf("NewComment: failed to get repos: %v", err)
156
-
return
157
-
}
158
-
159
-
recipients.Insert(syntax.DID(pull.Repo.Did))
160
-
for _, p := range pull.Participants() {
161
-
recipients.Insert(syntax.DID(p))
162
-
}
163
-
164
-
entityType = "pull"
165
-
entityId = pull.AtUri().String()
166
-
repoId = &pull.Repo.Id
167
-
p := int64(pull.ID)
168
-
pullId = &p
169
-
default:
170
-
return // no-op
171
}
172
-
173
-
for _, m := range comment.Mentions {
174
recipients.Remove(m)
175
}
176
177
n.notifyEvent(
178
-
comment.Did,
179
recipients,
180
-
models.NotificationTypeIssueCommented,
181
entityType,
182
entityId,
183
repoId,
···
185
pullId,
186
)
187
n.notifyEvent(
188
-
comment.Did,
189
-
sets.Collect(slices.Values(comment.Mentions)),
190
models.NotificationTypeUserMentioned,
191
entityType,
192
entityId,
···
196
)
197
}
198
199
-
func (n *databaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) {
200
-
// no-op
201
-
}
202
-
203
-
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
204
-
collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt()))
205
if err != nil {
206
-
log.Printf("failed to fetch collaborators: %v", err)
207
return
208
}
209
210
-
// build the recipients list
211
-
// - owner of the repo
212
-
// - collaborators in the repo
213
-
// - remove users already mentioned
214
recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
215
-
for _, c := range collaborators {
216
-
recipients.Insert(c.SubjectDid)
217
}
218
for _, m := range mentions {
219
recipients.Remove(m)
220
}
221
222
-
actorDid := syntax.DID(issue.Did)
223
entityType := "issue"
224
entityId := issue.AtUri().String()
225
repoId := &issue.Repo.Id
···
229
n.notifyEvent(
230
actorDid,
231
recipients,
232
-
models.NotificationTypeIssueCreated,
233
entityType,
234
entityId,
235
repoId,
···
309
actorDid,
310
recipients,
311
eventType,
312
entityType,
313
entityId,
314
repoId,
···
74
// no-op
75
}
76
77
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
78
+
collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt()))
79
if err != nil {
80
+
log.Printf("failed to fetch collaborators: %v", err)
81
return
82
}
83
84
+
// build the recipients list
85
+
// - owner of the repo
86
+
// - collaborators in the repo
87
+
// - remove users already mentioned
88
+
recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
89
+
for _, c := range collaborators {
90
+
recipients.Insert(c.SubjectDid)
91
}
92
+
for _, m := range mentions {
93
recipients.Remove(m)
94
}
95
96
+
actorDid := syntax.DID(issue.Did)
97
+
entityType := "issue"
98
+
entityId := issue.AtUri().String()
99
+
repoId := &issue.Repo.Id
100
+
issueId := &issue.Id
101
+
var pullId *int64
102
+
103
n.notifyEvent(
104
+
actorDid,
105
recipients,
106
+
models.NotificationTypeIssueCreated,
107
entityType,
108
entityId,
109
repoId,
···
111
pullId,
112
)
113
n.notifyEvent(
114
+
actorDid,
115
+
sets.Collect(slices.Values(mentions)),
116
models.NotificationTypeUserMentioned,
117
entityType,
118
entityId,
···
122
)
123
}
124
125
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
126
+
issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt))
127
if err != nil {
128
+
log.Printf("NewIssueComment: failed to get issues: %v", err)
129
+
return
130
+
}
131
+
if len(issues) == 0 {
132
+
log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt)
133
return
134
}
135
+
issue := issues[0]
136
137
+
// built the recipients list:
138
+
// - the owner of the repo
139
+
// - | if the comment is a reply -> everybody on that thread
140
+
// | if the comment is a top level -> just the issue owner
141
+
// - remove mentioned users from the recipients list
142
recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
143
+
144
+
if comment.IsReply() {
145
+
// if this comment is a reply, then notify everybody in that thread
146
+
parentAtUri := *comment.ReplyTo
147
+
148
+
// find the parent thread, and add all DIDs from here to the recipient list
149
+
for _, t := range issue.CommentList() {
150
+
if t.Self.AtUri().String() == parentAtUri {
151
+
for _, p := range t.Participants() {
152
+
recipients.Insert(p)
153
+
}
154
+
}
155
+
}
156
+
} else {
157
+
// not a reply, notify just the issue author
158
+
recipients.Insert(syntax.DID(issue.Did))
159
}
160
+
161
for _, m := range mentions {
162
recipients.Remove(m)
163
}
164
165
+
actorDid := syntax.DID(comment.Did)
166
entityType := "issue"
167
entityId := issue.AtUri().String()
168
repoId := &issue.Repo.Id
···
172
n.notifyEvent(
173
actorDid,
174
recipients,
175
+
models.NotificationTypeIssueCommented,
176
entityType,
177
entityId,
178
repoId,
···
252
actorDid,
253
recipients,
254
eventType,
255
+
entityType,
256
+
entityId,
257
+
repoId,
258
+
issueId,
259
+
pullId,
260
+
)
261
+
}
262
+
263
+
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
264
+
pull, err := db.GetPull(n.db,
265
+
syntax.ATURI(comment.RepoAt),
266
+
comment.PullId,
267
+
)
268
+
if err != nil {
269
+
log.Printf("NewPullComment: failed to get pulls: %v", err)
270
+
return
271
+
}
272
+
273
+
repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt))
274
+
if err != nil {
275
+
log.Printf("NewPullComment: failed to get repos: %v", err)
276
+
return
277
+
}
278
+
279
+
// build up the recipients list:
280
+
// - repo owner
281
+
// - all pull participants
282
+
// - remove those already mentioned
283
+
recipients := sets.Singleton(syntax.DID(repo.Did))
284
+
for _, p := range pull.Participants() {
285
+
recipients.Insert(syntax.DID(p))
286
+
}
287
+
for _, m := range mentions {
288
+
recipients.Remove(m)
289
+
}
290
+
291
+
actorDid := syntax.DID(comment.OwnerDid)
292
+
eventType := models.NotificationTypePullCommented
293
+
entityType := "pull"
294
+
entityId := pull.AtUri().String()
295
+
repoId := &repo.Id
296
+
var issueId *int64
297
+
p := int64(pull.ID)
298
+
pullId := &p
299
+
300
+
n.notifyEvent(
301
+
actorDid,
302
+
recipients,
303
+
eventType,
304
+
entityType,
305
+
entityId,
306
+
repoId,
307
+
issueId,
308
+
pullId,
309
+
)
310
+
n.notifyEvent(
311
+
actorDid,
312
+
sets.Collect(slices.Values(mentions)),
313
+
models.NotificationTypeUserMentioned,
314
entityType,
315
entityId,
316
repoId,
+8
-8
appview/notify/merged_notifier.go
+8
-8
appview/notify/merged_notifier.go
···
53
m.fanout("DeleteStar", ctx, star)
54
}
55
56
-
func (m *mergedNotifier) NewComment(ctx context.Context, comment *models.Comment) {
57
-
m.fanout("NewComment", ctx, comment)
58
-
}
59
-
60
-
func (m *mergedNotifier) DeleteComment(ctx context.Context, comment *models.Comment) {
61
-
m.fanout("DeleteComment", ctx, comment)
62
-
}
63
-
64
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
65
m.fanout("NewIssue", ctx, issue, mentions)
66
}
67
68
func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
···
83
84
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
85
m.fanout("NewPull", ctx, pull)
86
}
87
88
func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
···
53
m.fanout("DeleteStar", ctx, star)
54
}
55
56
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
57
m.fanout("NewIssue", ctx, issue, mentions)
58
+
}
59
+
60
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
61
+
m.fanout("NewIssueComment", ctx, comment, mentions)
62
}
63
64
func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
···
79
80
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
81
m.fanout("NewPull", ctx, pull)
82
+
}
83
+
84
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
85
+
m.fanout("NewPullComment", ctx, comment, mentions)
86
}
87
88
func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
+7
-7
appview/notify/notifier.go
+7
-7
appview/notify/notifier.go
···
13
NewStar(ctx context.Context, star *models.Star)
14
DeleteStar(ctx context.Context, star *models.Star)
15
16
-
NewComment(ctx context.Context, comment *models.Comment)
17
-
DeleteComment(ctx context.Context, comment *models.Comment)
18
-
19
NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID)
20
NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue)
21
DeleteIssue(ctx context.Context, issue *models.Issue)
22
···
24
DeleteFollow(ctx context.Context, follow *models.Follow)
25
26
NewPull(ctx context.Context, pull *models.Pull)
27
NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull)
28
29
UpdateProfile(ctx context.Context, profile *models.Profile)
···
43
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
44
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
45
46
-
func (m *BaseNotifier) NewComment(ctx context.Context, comment *models.Comment) {}
47
-
func (m *BaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) {}
48
-
49
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {}
50
func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {}
51
func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
52
53
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
54
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
55
56
-
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
57
func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {}
58
59
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
···
13
NewStar(ctx context.Context, star *models.Star)
14
DeleteStar(ctx context.Context, star *models.Star)
15
16
NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID)
17
+
NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID)
18
NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue)
19
DeleteIssue(ctx context.Context, issue *models.Issue)
20
···
22
DeleteFollow(ctx context.Context, follow *models.Follow)
23
24
NewPull(ctx context.Context, pull *models.Pull)
25
+
NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID)
26
NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull)
27
28
UpdateProfile(ctx context.Context, profile *models.Profile)
···
42
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
43
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
44
45
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {}
46
+
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
47
+
}
48
func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {}
49
func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
50
51
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
52
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
53
54
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
55
+
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) {
56
+
}
57
func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {}
58
59
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
+20
-5
appview/notify/posthog/notifier.go
+20
-5
appview/notify/posthog/notifier.go
···
86
}
87
}
88
89
func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
90
err := n.client.Enqueue(posthog.Capture{
91
DistinctId: pull.OwnerDid,
···
165
}
166
}
167
168
-
func (n *posthogNotifier) NewComment(ctx context.Context, comment *models.Comment) {
169
err := n.client.Enqueue(posthog.Capture{
170
-
DistinctId: comment.Did.String(),
171
-
Event: "new_comment",
172
Properties: posthog.Properties{
173
-
"subject_at": comment.Subject,
174
-
"mentions": comment.Mentions,
175
},
176
})
177
if err != nil {
···
86
}
87
}
88
89
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
90
+
err := n.client.Enqueue(posthog.Capture{
91
+
DistinctId: comment.OwnerDid,
92
+
Event: "new_pull_comment",
93
+
Properties: posthog.Properties{
94
+
"repo_at": comment.RepoAt,
95
+
"pull_id": comment.PullId,
96
+
"mentions": mentions,
97
+
},
98
+
})
99
+
if err != nil {
100
+
log.Println("failed to enqueue posthog event:", err)
101
+
}
102
+
}
103
+
104
func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
105
err := n.client.Enqueue(posthog.Capture{
106
DistinctId: pull.OwnerDid,
···
180
}
181
}
182
183
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
184
err := n.client.Enqueue(posthog.Capture{
185
+
DistinctId: comment.Did,
186
+
Event: "new_issue_comment",
187
Properties: posthog.Properties{
188
+
"issue_at": comment.IssueAt,
189
+
"mentions": mentions,
190
},
191
})
192
if err != nil {
+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)
+20
-7
appview/pages/funcmap.go
+20
-7
appview/pages/funcmap.go
···
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
···
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, ".", "_")
···
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
···
366
return p.AvatarUrl(handle, "")
367
},
368
"langColor": enry.GetColor,
369
+
"reverse": func(s any) any {
370
+
if s == nil {
371
+
return nil
372
+
}
373
+
374
+
v := reflect.ValueOf(s)
375
+
376
+
if v.Kind() != reflect.Slice {
377
+
return s
378
+
}
379
+
380
+
length := v.Len()
381
+
reversed := reflect.MakeSlice(v.Type(), length, length)
382
383
+
for i := range length {
384
+
reversed.Index(i).Set(v.Index(length - 1 - i))
385
+
}
386
+
387
+
return reversed.Interface()
388
+
},
389
"normalizeForHtmlId": func(s string) string {
390
normalized := strings.ReplaceAll(s, ":", "_")
391
normalized = strings.ReplaceAll(normalized, ".", "_")
+18
-5
appview/pages/pages.go
+18
-5
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 {
···
988
LoggedInUser *oauth.User
989
RepoInfo repoinfo.RepoInfo
990
Issue *models.Issue
991
-
Comment *models.Comment
992
}
993
994
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
···
999
LoggedInUser *oauth.User
1000
RepoInfo repoinfo.RepoInfo
1001
Issue *models.Issue
1002
-
Comment *models.Comment
1003
}
1004
1005
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
···
1010
LoggedInUser *oauth.User
1011
RepoInfo repoinfo.RepoInfo
1012
Issue *models.Issue
1013
-
Comment *models.Comment
1014
}
1015
1016
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
···
1021
LoggedInUser *oauth.User
1022
RepoInfo repoinfo.RepoInfo
1023
Issue *models.Issue
1024
-
Comment *models.Comment
1025
}
1026
1027
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
···
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
···
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 {
···
999
LoggedInUser *oauth.User
1000
RepoInfo repoinfo.RepoInfo
1001
Issue *models.Issue
1002
+
Comment *models.IssueComment
1003
}
1004
1005
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
···
1010
LoggedInUser *oauth.User
1011
RepoInfo repoinfo.RepoInfo
1012
Issue *models.Issue
1013
+
Comment *models.IssueComment
1014
}
1015
1016
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
···
1021
LoggedInUser *oauth.User
1022
RepoInfo repoinfo.RepoInfo
1023
Issue *models.Issue
1024
+
Comment *models.IssueComment
1025
}
1026
1027
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
···
1032
LoggedInUser *oauth.User
1033
RepoInfo repoinfo.RepoInfo
1034
Issue *models.Issue
1035
+
Comment *models.IssueComment
1036
}
1037
1038
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
···
1103
MergeCheck types.MergeCheckResponse
1104
ResubmitCheck ResubmitResult
1105
Pipelines map[string]models.Pipeline
1106
+
Diff *types.NiceDiff
1107
+
DiffOpts types.DiffOpts
1108
1109
OrderedReactionKinds []models.ReactionKind
1110
Reactions map[models.ReactionKind]models.ReactionDisplayData
+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
+1
appview/pages/templates/fragments/tabSelector.html
+1
appview/pages/templates/fragments/tabSelector.html
+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" />
+1
-5
appview/pages/templates/layouts/fragments/topbar.html
+1
-5
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
+1
-1
appview/pages/templates/layouts/repobase.html
+1
-1
appview/pages/templates/layouts/repobase.html
+1
-1
appview/pages/templates/repo/fragments/diff.html
+1
-1
appview/pages/templates/repo/fragments/diff.html
···
18
{{ range $idx, $hunk := $diff }}
19
{{ with $hunk }}
20
<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 }}">
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>
···
18
{{ range $idx, $hunk := $diff }}
19
{{ with $hunk }}
20
<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 }}">
21
+
<summary class="list-none cursor-pointer sticky top-12">
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>
+1
-8
appview/pages/templates/repo/fragments/diffChangedFiles.html
+1
-8
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 }}
···
1
{{ define "repo/fragments/diffChangedFiles" }}
2
{{ $fileTree := fileTree .ChangedFiles }}
3
<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">
4
+
{{ template "repo/fragments/fileTree" $fileTree }}
5
</section>
6
{{ 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
+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 }}
···
+3
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
+3
-2
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.String }}
4
{{ template "hats" $ }}
5
{{ template "timestamp" . }}
6
-
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }}
7
{{ if and $isCommentOwner (not .Comment.Deleted) }}
8
{{ template "editIssueComment" . }}
9
{{ template "deleteIssueComment" . }}
···
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) }}
9
{{ template "editIssueComment" . }}
10
{{ template "deleteIssueComment" . }}
+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 }}
+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>
+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
+
+20
appview/pages/templates/repo/pulls/fragments/replyPullCommentPlaceholder.html
+20
appview/pages/templates/repo/pulls/fragments/replyPullCommentPlaceholder.html
···
···
1
+
{{ define "repo/pulls/fragments/replyPullCommentPlaceholder" }}
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="/{{ .Submission.ID }}/reply"
14
+
hx-trigger="focus"
15
+
hx-target="closest div"
16
+
hx-swap="outerHTML"
17
+
>
18
+
</input>
19
+
</div>
20
+
{{ end }}
+1
-1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+1
-1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
···
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>
···
18
{{ $lastSubmission := index .Submissions $latestRound }}
19
{{ $commentCount := len $lastSubmission.Comments }}
20
{{ if and $pipeline $pipeline.Id }}
21
+
{{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }}
22
<span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span>
23
{{ end }}
24
<span>
+337
-80
appview/pages/templates/repo/pulls/pull.html
+337
-80
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
···
26
"Backlinks" $.Backlinks) }}
27
{{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }}
28
</div>
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" . }}
···
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" }}
···
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.Did.String }}
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 }}
···
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>
···
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 col-span-full flex-grow flex flex-col gap-4">
11
+
{{ block "contentLayout" . }}
12
+
{{ block "content" . }}{{ end }}
13
+
{{ end }}
14
+
</div>
15
+
{{ end }}
16
+
17
{{ define "repoContentLayout" }}
18
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-y-2 gap-x-4 w-full">
19
+
<div class="col-span-1 md:col-span-7">
20
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white h-full">
21
{{ block "repoContent" . }}{{ end }}
22
</section>
23
{{ block "repoAfter" . }}{{ end }}
24
</div>
25
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
26
{{ template "repo/fragments/labelPanel"
27
(dict "RepoInfo" $.RepoInfo
28
"Defs" $.LabelDefs
···
34
"Backlinks" $.Backlinks) }}
35
{{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }}
36
</div>
37
+
38
+
<style>
39
+
#filesToggle:checked ~ div label[for="filesToggle"] .show-text { display: none; }
40
+
#filesToggle:checked ~ div label[for="filesToggle"] .hide-text { display: inline; }
41
+
#filesToggle:not(:checked) ~ div label[for="filesToggle"] .hide-text { display: none; }
42
+
43
+
#filesToggle:checked ~ div div#files { width: 10vw; margin-right: 1rem; }
44
+
#filesToggle:not(:checked) ~ div div#files { width: 0; display: hidden; margin-right: 0; }
45
+
46
+
#subsToggle:checked ~ div div#subs { width: 25vw; margin-left: 1rem; }
47
+
#subsToggle:not(:checked) ~ div div#subs { width: 0; display: hidden; margin-left: 0; }
48
+
</style>
49
+
50
+
<!-- Checkboxes must come first as siblings -->
51
+
<input type="checkbox" id="filesToggle" class="peer/files hidden" checked/>
52
+
<input type="checkbox" id="subsToggle" class="peer/subs hidden" checked/>
53
+
54
+
<!-- Top bar with controls -->
55
+
<div class="sticky top-0 z-30 bg-slate-100 dark:bg-gray-900 flex items-center gap-2 col-span-full h-12">
56
+
<label for="filesToggle" class="inline-flex items-center justify-center rounded cursor-pointer p-2 text-normal font-normal normalcase">
57
+
<span class="show-text">{{ i "panel-left-open" "size-5" }}</span>
58
+
<span class="hide-text">{{ i "panel-left-close" "size-5" }}</span>
59
+
</label>
60
+
{{ template "repo/fragments/diffStatPill" .Diff.Stat }}
61
+
{{ .Diff.Stat.FilesChanged }} changed file{{ if ne .Diff.Stat.FilesChanged 1 }}s{{ end }}
62
+
<div class="flex-grow"></div>
63
+
{{ template "repo/fragments/diffOpts" .DiffOpts }}
64
+
<label for="subsToggle" class="inline-flex items-center justify-center rounded cursor-pointer p-2">
65
+
{{ i "message-square-more" "size-5" }}
66
+
</label>
67
+
</div>
68
+
69
+
<div class="flex col-span-full">
70
+
<!-- left panel -->
71
+
<div id="files" class="w-0 overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12">
72
+
{{ template "repo/fragments/diffChangedFiles" .Diff }}
73
+
</div>
74
+
75
+
<!-- main content -->
76
+
<div class="flex-1 min-w-0 sticky top-12 pb-12">
77
+
{{ template "repo/fragments/diff" (list .Diff .DiffOpts) }}
78
+
</div>
79
+
80
+
<!-- right panel -->
81
+
<div id="subs" class="w-0 overflow-hidden max-h-screen flex flex-col sticky top-12 pb-12">
82
+
<div class="z-20 sticky top-0 rounded-t p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
83
+
<h2 class="font-bold uppercase">history</h2>
84
+
</div>
85
+
<div class="flex flex-col-reverse gap-4 overflow-y-auto">
86
+
{{ template "submissions2" . }}
87
+
</div>
88
+
</div>
89
+
</div>
90
</div>
91
{{ end }}
92
93
{{ define "repoContent" }}
94
{{ template "repo/pulls/fragments/pullHeader" . }}
95
{{ if .Pull.IsStacked }}
96
<div class="mt-8">
97
{{ template "repo/pulls/fragments/pullStack" . }}
···
100
{{ end }}
101
102
{{ define "repoAfter" }}
103
+
<div id="pull-close"></div>
104
+
<div id="pull-reopen"></div>
105
+
{{ end }}
106
+
107
+
{{ define "submissions2" }}
108
+
{{ $lastIdx := sub (len .Pull.Submissions) 1 }}
109
+
{{ range $ridx, $item := reverse .Pull.Submissions }}
110
+
{{ $idx := sub $lastIdx $ridx }}
111
+
<div class="rounded border border-gray-200 dark:border-gray-700 w-full shadow-sm bg-gray-50 dark:bg-gray-800/50">
112
+
{{ with $item }}
113
+
{{ $patches := .AsFormatPatch }}
114
+
{{ $round := .RoundNumber }}
115
+
<div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2">
116
+
<div class="flex-shrink-0">
117
+
<img
118
+
src="{{ tinyAvatar $.Pull.OwnerDid }}"
119
+
alt=""
120
+
class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900"
121
+
/>
122
+
</div>
123
+
<!-- right column: name and body in two rows -->
124
+
<div class="flex-1 min-w-0 flex flex-col gap-1">
125
+
<div class="flex gap-2 items-center justify-between mb-1">
126
+
<span class="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
127
+
{{ resolve $.Pull.OwnerDid }} submitted v{{ $round }}
128
+
<span class="select-none before:content-['\00B7']"></span>
129
+
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ $round }}">{{ template "repo/fragments/shortTimeAgo" .Created }}</a>
130
+
</span>
131
+
{{ if ne $idx 0 }}
132
+
<a class="flex items-center gap-2 no-underline hover:no-underline text-sm"
133
+
hx-boost="true"
134
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{$round}}/interdiff">
135
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
136
+
<span class="hidden md:inline">interdiff</span>
137
+
</a>
138
+
{{ end }}
139
+
</div>
140
+
<details class="group">
141
+
<summary class="list-none cursor-pointer flex items-center gap-2">
142
+
<span>{{ i "git-commit-horizontal" "w-4 h-4" }}</span>
143
+
{{ len $patches }} commit{{ if ne (len $patches) 1 }}s{{ end }}
144
+
</summary>
145
+
{{ range $patches }}
146
+
<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">
147
+
<div class="flex items-baseline gap-2">
148
+
<div>
149
+
<!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches -->
150
+
{{ $fullRepo := "" }}
151
+
{{ if and $.Pull.IsForkBased $.Pull.PullSource.Repo }}
152
+
{{ $fullRepo = printf "%s/%s" $.Pull.OwnerDid $.Pull.PullSource.Repo.Name }}
153
+
{{ else if $.Pull.IsBranchBased }}
154
+
{{ $fullRepo = $.RepoInfo.FullName }}
155
+
{{ end }}
156
+
157
+
<!-- if $fullRepo was resolved, link to it, otherwise just span without a link -->
158
+
{{ if $fullRepo }}
159
+
<a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-600 dark:text-gray-300">{{ slice .SHA 0 8 }}</a>
160
+
{{ else }}
161
+
<span class="font-mono">{{ slice .SHA 0 8 }}</span>
162
+
{{ end }}
163
+
</div>
164
+
165
+
<div>
166
+
<span>{{ .Title | description }}</span>
167
+
{{ if gt (len .Body) 0 }}
168
+
<button
169
+
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"
170
+
hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')"
171
+
>
172
+
{{ i "ellipsis" "w-3 h-3" }}
173
+
</button>
174
+
{{ end }}
175
+
{{ if gt (len .Body) 0 }}
176
+
<p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2">{{ nl2br .Body }}</p>
177
+
{{ end }}
178
+
</div>
179
+
</div>
180
+
</div>
181
+
{{ end }}
182
+
</details>
183
+
<div>
184
+
{{ block "pipelineStatus" (list $ .) }} {{ end }}
185
+
</div>
186
+
{{ if eq $lastIdx .RoundNumber }}
187
+
{{ block "mergeCheck" $ }} {{ end }}
188
+
{{ end }}
189
+
</div>
190
+
</div>
191
+
<div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700">
192
+
{{ range $cidx, $c := .Comments }}
193
+
<div id="comment-{{$c.ID}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto">
194
+
<!-- left column: profile picture -->
195
+
<div class="flex-shrink-0">
196
+
<img
197
+
src="{{ tinyAvatar $c.OwnerDid }}"
198
+
alt=""
199
+
class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900"
200
+
/>
201
+
</div>
202
+
<!-- right column: name and body in two rows -->
203
+
<div class="flex-1 min-w-0">
204
+
<!-- Row 1: Author and timestamp -->
205
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1">
206
+
<span>{{ resolve $c.OwnerDid }}</span>
207
+
<span class="before:content-['ยท']"></span>
208
+
<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>
209
+
</div>
210
+
<!-- Row 2: Body text -->
211
+
<div class="prose dark:prose-invert mt-1">
212
+
{{ $c.Body | markdown }}
213
+
</div>
214
+
</div>
215
+
</div>
216
+
{{ end }}
217
+
</div>
218
+
{{ end }}
219
+
{{ if eq $lastIdx .RoundNumber }}
220
+
{{ block "mergeStatus" $ }} {{ end }}
221
+
{{ block "resubmitStatus" $ }} {{ end }}
222
+
{{ end }}
223
+
{{ if $.LoggedInUser }}
224
+
{{ template "repo/pulls/fragments/pullActions"
225
+
(dict
226
+
"LoggedInUser" $.LoggedInUser
227
+
"Pull" $.Pull
228
+
"RepoInfo" $.RepoInfo
229
+
"RoundNumber" .RoundNumber
230
+
"MergeCheck" $.MergeCheck
231
+
"ResubmitCheck" $.ResubmitCheck
232
+
"BranchDeleteStatus" $.BranchDeleteStatus
233
+
"Stack" $.Stack) }}
234
+
{{ else }}
235
+
<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">
236
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
237
+
sign up
238
+
</a>
239
+
<span class="text-gray-500 dark:text-gray-400">or</span>
240
+
<a href="/login" class="underline">login</a>
241
+
to add to the discussion
242
+
</div>
243
+
{{ end }}
244
+
</div>
245
+
{{ end }}
246
+
{{ end }}
247
+
248
+
{{ define "newComment" }}
249
+
{{ $root := index . 0 }}
250
+
{{ $submission := index . 1 }}
251
+
<form
252
+
id="comment-form"
253
+
hx-post="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $submission.RoundNumber }}/comment"
254
+
hx-on::after-request="if(event.detail.successful) this.reset()"
255
+
>
256
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full">
257
+
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
258
+
{{ template "user/fragments/picHandleLink" $root.LoggedInUser.Did }}
259
</div>
260
+
<textarea
261
+
id="comment-textarea"
262
+
name="body"
263
+
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
264
+
placeholder="Add to the discussion"
265
+
rows="8"
266
+
></textarea>
267
+
<div id="pull-comment"></div>
268
+
</div>
269
+
{{ template "replyActions" . }}
270
+
</form>
271
+
{{ end }}
272
273
+
{{ define "replyActions" }}
274
+
<div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm">
275
+
{{ template "cancel" . }}
276
+
{{ template "reply" . }}
277
+
</div>
278
+
{{ end }}
279
+
280
+
{{ define "cancel" }}
281
+
<button
282
+
class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group"
283
+
hx-get="TODO"
284
+
hx-target="TODO"
285
+
hx-swap="outerHTML">
286
+
{{ i "x" "size-4" }}
287
+
cancel
288
+
</button>
289
+
{{ end }}
290
+
291
+
{{ define "reply" }}
292
+
<button
293
+
id="TODO"
294
+
type="submit"
295
+
class="btn-create flex items-center gap-2 no-underline hover:no-underline">
296
+
{{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }}
297
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
298
+
reply
299
+
</button>
300
{{ end }}
301
302
{{ define "submissions" }}
···
414
415
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
416
{{ range $cidx, $c := .Comments }}
417
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
418
{{ if gt $cidx 0 }}
419
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
420
{{ end }}
421
<div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1">
422
+
{{ template "user/fragments/picHandleLink" $c.OwnerDid }}
423
<span class="before:content-['ยท']"></span>
424
+
<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>
425
</div>
426
<div class="prose dark:prose-invert">
427
{{ $c.Body | markdown }}
···
463
{{ end }}
464
{{ end }}
465
466
+
{{ define "mergeCheck" }}
467
+
{{ $isOpen := .Pull.State.IsOpen }}
468
+
{{ if and $isOpen .MergeCheck .MergeCheck.Error }}
469
+
<div class="flex items-center gap-2">
470
+
{{ i "triangle-alert" "w-4 h-4 text-red-500 dark:text-red-300" }}
471
+
{{ .MergeCheck.Error }}
472
+
</div>
473
+
{{ else if and $isOpen .MergeCheck .MergeCheck.IsConflicted }}
474
+
<details class="group">
475
+
<summary class="flex items-center justify-between cursor-pointer list-none">
476
+
<div class="flex items-center gap-2 ">
477
+
{{ i "triangle-alert" "w-4 h-4" }}
478
+
<span class="font-medium">merge conflicts detected</span>
479
+
</div>
480
+
<div>
481
+
<span class="group-open:hidden inline">{{ i "chevrons-up-down" "w-4 h-4" }}</span>
482
+
<span class="hidden group-open:inline">{{ i "chevrons-down-up" "w-4 h-4" }}</span>
483
+
</div>
484
+
</summary>
485
+
{{ if gt (len .MergeCheck.Conflicts) 0 }}
486
+
<ul class="space-y-1 mt-2">
487
+
{{ range .MergeCheck.Conflicts }}
488
+
{{ if .Filename }}
489
+
<li class="flex items-center">
490
+
{{ i "file-warning" "inline-flex w-4 h-4 mr-1.5 text-red-500 dark:text-red-300 flex-shrink-0" }}
491
+
<span class="font-mono" style="word-break: keep-all; overflow-wrap: break-word;">{{ .Filename }}</span>
492
+
</li>
493
+
{{ else if .Reason }}
494
+
<li class="flex items-center">
495
+
{{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }}
496
+
<span>{{.Reason}}</span>
497
+
</li>
498
+
{{ end }}
499
+
{{ end }}
500
+
</ul>
501
+
{{ end }}
502
+
</details>
503
+
{{ else if and $isOpen .MergeCheck }}
504
+
<div class="flex items-center gap-2">
505
+
{{ i "check" "w-4 h-4 text-green-600 dark:text-green-500" }}
506
+
<span>no conflicts, ready to merge</span>
507
+
</div>
508
+
{{ end }}
509
+
{{ end }}
510
+
511
{{ define "mergeStatus" }}
512
{{ if .Pull.State.IsClosed }}
513
+
<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">
514
<div class="flex items-center gap-2 text-black dark:text-white">
515
{{ i "ban" "w-4 h-4" }}
516
<span class="font-medium">closed without merging</span
···
518
</div>
519
</div>
520
{{ else if .Pull.State.IsMerged }}
521
+
<div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative">
522
<div class="flex items-center gap-2 text-purple-500 dark:text-purple-300">
523
{{ i "git-merge" "w-4 h-4" }}
524
<span class="font-medium">pull request successfully merged</span
···
526
</div>
527
</div>
528
{{ else if .Pull.State.IsDeleted }}
529
+
<div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative">
530
<div class="flex items-center gap-2 text-red-500 dark:text-red-300">
531
{{ i "git-pull-request-closed" "w-4 h-4" }}
532
<span class="font-medium">This pull has been deleted (possibly by jj abandon or jj squash)</span>
533
</div>
534
</div>
535
{{ end }}
536
{{ end }}
537
538
{{ define "resubmitStatus" }}
539
{{ if .ResubmitCheck.Yes }}
540
+
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative">
541
<div class="flex items-center gap-2 text-amber-500 dark:text-amber-300">
542
{{ i "triangle-alert" "w-4 h-4" }}
543
<span class="font-medium">this branch has been updated, consider resubmitting</span>
···
553
{{ with $pipeline }}
554
{{ $id := .Id }}
555
{{ if .Statuses }}
556
+
<details>
557
+
<summary class="cursor-pointer list-none">{{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" false) }}</summary>
558
+
<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">
559
+
{{ range $name, $all := .Statuses }}
560
+
<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">
561
+
<div
562
+
class="flex gap-2 items-center justify-between p-2">
563
+
{{ $lastStatus := $all.Latest }}
564
+
{{ $kind := $lastStatus.Status.String }}
565
566
+
<div id="left" class="flex items-center gap-2 flex-shrink-0">
567
+
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
568
+
{{ $name }}
569
+
</div>
570
+
<div id="right" class="flex items-center gap-2 flex-shrink-0">
571
+
<span class="font-bold">{{ $kind }}</span>
572
+
{{ if .TimeTaken }}
573
+
{{ template "repo/fragments/duration" .TimeTaken }}
574
+
{{ else }}
575
+
{{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }}
576
+
{{ end }}
577
+
</div>
578
+
</div>
579
+
</a>
580
+
{{ end }}
581
</div>
582
+
</details>
583
{{ end }}
584
{{ end }}
585
{{ end }}
+1
-1
appview/pages/templates/repo/pulls/pulls.html
+1
-1
appview/pages/templates/repo/pulls/pulls.html
···
136
{{ $pipeline := index $.Pipelines .LatestSha }}
137
{{ if and $pipeline $pipeline.Id }}
138
<span class="before:content-['ยท']"></span>
139
+
{{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }}
140
{{ end }}
141
142
{{ $state := .Labels }}
+2
-2
appview/pulls/opengraph.go
+2
-2
appview/pulls/opengraph.go
···
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
}
···
277
}
278
279
// Get comment count from database
280
-
comments, err := db.GetComments(s.db, orm.FilterEq("subject_at", pull.AtUri()))
281
if err != nil {
282
log.Printf("failed to get pull comments: %v", err)
283
}
···
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
}
···
277
}
278
279
// Get comment count from database
280
+
comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID))
281
if err != nil {
282
log.Printf("failed to get pull comments: %v", err)
283
}
+34
-26
appview/pulls/pulls.go
+34
-26
appview/pulls/pulls.go
···
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),
238
Pull: pull,
···
243
MergeCheck: mergeCheckResponse,
244
ResubmitCheck: resubmitResult,
245
Pipelines: m,
246
247
OrderedReactionKinds: models.OrderedReactionKinds,
248
Reactions: reactionMap,
249
UserReacted: userReactions,
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 {
···
741
}
742
defer tx.Rollback()
743
744
-
comment := models.Comment{
745
-
Did: syntax.DID(user.Did),
746
-
Rkey: tid.TID(),
747
-
Subject: pull.AtUri(),
748
-
ReplyTo: nil,
749
-
Body: body,
750
-
Created: time.Now(),
751
-
Mentions: mentions,
752
-
References: references,
753
-
PullSubmissionId: &pull.Submissions[roundNumber].ID,
754
-
}
755
-
if err = comment.Validate(); err != nil {
756
-
log.Println("failed to validate comment", err)
757
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
758
-
return
759
-
}
760
-
record := comment.AsRecord()
761
762
client, err := s.oauth.AuthorizedClient(r)
763
if err != nil {
···
765
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
766
return
767
}
768
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
769
-
Collection: tangled.CommentNSID,
770
Repo: user.Did,
771
-
Rkey: comment.Rkey,
772
Record: &lexutil.LexiconTypeDecoder{
773
-
Val: &record,
774
},
775
})
776
if err != nil {
···
779
return
780
}
781
782
// Create the pull comment in the database with the commentAt field
783
-
err = db.PutComment(tx, &comment)
784
if err != nil {
785
log.Println("failed to create pull comment", err)
786
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
794
return
795
}
796
797
-
s.notifier.NewComment(r.Context(), &comment)
798
799
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
800
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id))
801
return
802
}
803
}
···
232
defs[l.AtUri().String()] = &l
233
}
234
235
+
patch := pull.LatestSubmission().CombinedPatch()
236
+
diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
237
+
var diffOpts types.DiffOpts
238
+
if d := r.URL.Query().Get("diff"); d == "split" {
239
+
diffOpts.Split = true
240
+
}
241
+
242
+
log.Println(s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
243
LoggedInUser: user,
244
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
245
Pull: pull,
···
250
MergeCheck: mergeCheckResponse,
251
ResubmitCheck: resubmitResult,
252
Pipelines: m,
253
+
Diff: &diff,
254
+
DiffOpts: diffOpts,
255
256
OrderedReactionKinds: models.OrderedReactionKinds,
257
Reactions: reactionMap,
258
UserReacted: userReactions,
259
260
LabelDefs: defs,
261
+
}))
262
}
263
264
func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
···
750
}
751
defer tx.Rollback()
752
753
+
createdAt := time.Now().Format(time.RFC3339)
754
755
client, err := s.oauth.AuthorizedClient(r)
756
if err != nil {
···
758
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
759
return
760
}
761
+
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
762
+
Collection: tangled.RepoPullCommentNSID,
763
Repo: user.Did,
764
+
Rkey: tid.TID(),
765
Record: &lexutil.LexiconTypeDecoder{
766
+
Val: &tangled.RepoPullComment{
767
+
Pull: pull.AtUri().String(),
768
+
Body: body,
769
+
CreatedAt: createdAt,
770
+
},
771
},
772
})
773
if err != nil {
···
776
return
777
}
778
779
+
comment := &models.PullComment{
780
+
OwnerDid: user.Did,
781
+
RepoAt: f.RepoAt().String(),
782
+
PullId: pull.PullId,
783
+
Body: body,
784
+
CommentAt: atResp.Uri,
785
+
SubmissionId: pull.Submissions[roundNumber].ID,
786
+
Mentions: mentions,
787
+
References: references,
788
+
}
789
+
790
// Create the pull comment in the database with the commentAt field
791
+
commentId, err := db.NewPullComment(tx, comment)
792
if err != nil {
793
log.Println("failed to create pull comment", err)
794
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
802
return
803
}
804
805
+
s.notifier.NewPullComment(r.Context(), comment, mentions)
806
807
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
808
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId))
809
return
810
}
811
}
+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
}
+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
+
}
+1
-3
appview/state/router.go
+1
-3
appview/state/router.go
+1
-37
appview/state/state.go
+1
-37
appview/state/state.go
···
117
tangled.SpindleNSID,
118
tangled.StringNSID,
119
tangled.RepoIssueNSID,
120
-
tangled.CommentNSID,
121
tangled.LabelDefinitionNSID,
122
tangled.LabelOpNSID,
123
},
···
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
···
223
Allow: /
224
`
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) {
···
117
tangled.SpindleNSID,
118
tangled.StringNSID,
119
tangled.RepoIssueNSID,
120
+
tangled.RepoIssueCommentNSID,
121
tangled.LabelDefinitionNSID,
122
tangled.LabelOpNSID,
123
},
···
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
···
210
Allow: /
211
`
212
w.Write([]byte(robotsTxt))
213
}
214
215
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
+27
appview/validator/issue.go
+27
appview/validator/issue.go
···
4
"fmt"
5
"strings"
6
7
+
"tangled.org/core/appview/db"
8
"tangled.org/core/appview/models"
9
+
"tangled.org/core/orm"
10
)
11
+
12
+
func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error {
13
+
// if comments have parents, only ingest ones that are 1 level deep
14
+
if comment.ReplyTo != nil {
15
+
parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo))
16
+
if err != nil {
17
+
return fmt.Errorf("failed to fetch parent comment: %w", err)
18
+
}
19
+
if len(parents) != 1 {
20
+
return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents))
21
+
}
22
+
23
+
// depth check
24
+
parent := parents[0]
25
+
if parent.ReplyTo != nil {
26
+
return fmt.Errorf("incorrect depth, this comment is replying at depth >1")
27
+
}
28
+
}
29
+
30
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" {
31
+
return fmt.Errorf("body is empty after HTML sanitization")
32
+
}
33
+
34
+
return nil
35
+
}
36
37
func (v *Validator) ValidateIssue(issue *models.Issue) error {
38
if issue.Title == "" {
-1
cmd/cborgen/cborgen.go
-1
cmd/cborgen/cborgen.go
+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
+
}
+6
docs/logo.html
+6
docs/logo.html
+2
docs/template.html
+2
docs/template.html
···
74
${ x.svg() }
75
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
76
</button>
77
${ search.html() }
78
${ table-of-contents:toc.html() }
79
</div>
···
88
class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen
89
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
90
p-4 z-50 overflow-y-auto">
91
${ search.html() }
92
<div class="flex-1">
93
$if(toc-title)$
···
74
${ x.svg() }
75
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
76
</button>
77
+
${ logo.html() }
78
${ search.html() }
79
${ table-of-contents:toc.html() }
80
</div>
···
89
class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen
90
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
91
p-4 z-50 overflow-y-auto">
92
+
${ logo.html() }
93
${ search.html() }
94
<div class="flex-1">
95
$if(toc-title)$
+17
-2
flake.nix
+17
-2
flake.nix
···
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
});
98
in {
99
overlays.default = final: prev: {
100
-
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs;
101
};
102
103
packages = forAllSystems (system: let
···
106
staticPackages = mkPackageSet pkgs.pkgsStatic;
107
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
108
in {
109
-
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib docs;
110
111
pkgsStatic-appview = staticPackages.appview;
112
pkgsStatic-knot = staticPackages.knot;
113
pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped;
114
pkgsStatic-spindle = staticPackages.spindle;
115
pkgsStatic-sqlite-lib = staticPackages.sqlite-lib;
116
117
pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview;
118
pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot;
119
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped;
120
pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle;
121
122
treefmt-wrapper = pkgs.treefmt.withConfig {
123
settings.formatter = {
···
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
+
}
+13
input.css
+13
input.css
···
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
-51
lexicons/comment/comment.json
-51
lexicons/comment/comment.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.comment",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"subject",
14
-
"body",
15
-
"createdAt"
16
-
],
17
-
"properties": {
18
-
"subject": {
19
-
"type": "string",
20
-
"format": "at-uri"
21
-
},
22
-
"body": {
23
-
"type": "string"
24
-
},
25
-
"createdAt": {
26
-
"type": "string",
27
-
"format": "datetime"
28
-
},
29
-
"replyTo": {
30
-
"type": "string",
31
-
"format": "at-uri"
32
-
},
33
-
"mentions": {
34
-
"type": "array",
35
-
"items": {
36
-
"type": "string",
37
-
"format": "did"
38
-
}
39
-
},
40
-
"references": {
41
-
"type": "array",
42
-
"items": {
43
-
"type": "string",
44
-
"format": "at-uri"
45
-
}
46
-
}
47
-
}
48
-
}
49
-
}
50
-
}
51
-
}
···
+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
+4
nix/pkgs/docs.nix
+4
nix/pkgs/docs.nix
···
5
inter-fonts-src,
6
ibm-plex-mono-src,
7
lucide-src,
8
+
dolly,
9
src,
10
}:
11
runCommandLocal "docs" {} ''
···
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 \
+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
+
}
+2
-2
spindle/models/models.go
+2
-2
spindle/models/models.go
+4
-2
types/diff.go
+4
-2
types/diff.go
···
27
}
28
29
type DiffStat struct {
30
+
Insertions int64
31
+
Deletions int64
32
+
FilesChanged int
33
}
34
35
func (d *Diff) Stats() DiffStat {
···
38
stats.Insertions += f.LinesAdded
39
stats.Deletions += f.LinesDeleted
40
}
41
+
stats.FilesChanged = len(d.TextFragments)
42
return stats
43
}
44