forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+8269 -3699
.air
.tangled
workflows
.zed
api
appview
cache
session
config
db
idresolver
issues
middleware
models
notifications
notify
oauth
pages
markup
repoinfo
templates
brand
errors
fragments
goodfirstissues
knots
labels
fragments
layouts
notifications
fragments
repo
spindles
strings
timeline
user
pagination
pipelines
repo
reporesolver
serververify
settings
signup
spindles
spindleverify
state
validator
avatar
src
cmd
appview
cborgen
combinediff
interdiff
keyfetch
knotserver
consts
contrib
docker
rootfs
etc
s6-overlay
s6-rc.d
create-sshd-host-keys
knotserver
dependencies.d
sshd
user
contents.d
scripts
docs
eventconsumer
hook
idresolver
jetstream
keyfetch
knotclient
knotserver
legal
lexicons
nix
notifier
patchutil
rbac
spindle
systemd
tid
workflow
xrpc
errors
serviceauth
+5 -5
appview/state/repo.go
··· 699 699 return 700 700 } 701 701 702 - collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator) 702 + collaboratorIdent, err := s.idResolver.ResolveIdent(r.Context(), collaborator) 703 703 if err != nil { 704 704 w.Write([]byte("failed to resolve collaborator did to a handle")) 705 705 return ··· 993 993 return 994 994 } 995 995 996 - issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 996 + issueOwnerIdent, err := s.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 997 997 if err != nil { 998 998 log.Println("failed to resolve issue owner", err) 999 999 } ··· 1002 1002 for i, comment := range comments { 1003 1003 identsToResolve[i] = comment.OwnerDid 1004 1004 } 1005 - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1005 + resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 1006 1006 didHandleMap := make(map[string]string) 1007 1007 for _, identity := range resolvedIds { 1008 1008 if !identity.Handle.IsInvalidHandle() { ··· 1269 1269 return 1270 1270 } 1271 1271 1272 - identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid) 1272 + identity, err := s.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) 1273 1273 if err != nil { 1274 1274 log.Println("failed to resolve did") 1275 1275 return ··· 1550 1550 for i, issue := range issues { 1551 1551 identsToResolve[i] = issue.OwnerDid 1552 1552 } 1553 - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1553 + resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 1554 1554 didHandleMap := make(map[string]string) 1555 1555 for _, identity := range resolvedIds { 1556 1556 if !identity.Handle.IsInvalidHandle() {
+1 -1
appview/state/repo_util.go
··· 67 67 for _, v := range emailToDid { 68 68 dids = append(dids, v) 69 69 } 70 - resolvedIdents := s.resolver.ResolveIdents(context.Background(), dids) 70 + resolvedIdents := s.idResolver.ResolveIdents(context.Background(), dids) 71 71 72 72 didHandleMap := make(map[string]string) 73 73 for _, identity := range resolvedIdents {
-15
cmd/keyfetch/format.go
··· 1 - package main 2 - 3 - import ( 4 - "fmt" 5 - ) 6 - 7 - func formatKeyData(repoguardPath, gitDir, logPath, endpoint string, data []map[string]interface{}) string { 8 - var result string 9 - for _, entry := range data { 10 - result += fmt.Sprintf( 11 - `command="%s -base-dir %s -user %s -log-path %s -internal-api %s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s`+"\n", 12 - repoguardPath, gitDir, entry["did"], logPath, endpoint, entry["key"]) 13 - } 14 - return result 15 - }
-46
cmd/keyfetch/main.go
··· 1 - // This program must be configured to run as the sshd AuthorizedKeysCommand. 2 - // The format looks something like this: 3 - // Match User git 4 - // AuthorizedKeysCommand /keyfetch -internal-api http://localhost:5444 -repoguard-path /home/git/repoguard 5 - // AuthorizedKeysCommandUser nobody 6 - // 7 - // The command and its parent directories must be owned by root and set to 0755. Hence, the ideal location for this is 8 - // somewhere already owned by root so you don't have to mess with directory perms. 9 - 10 - package main 11 - 12 - import ( 13 - "encoding/json" 14 - "flag" 15 - "fmt" 16 - "io" 17 - "log" 18 - "net/http" 19 - ) 20 - 21 - func main() { 22 - endpoint := flag.String("internal-api", "http://localhost:5444", "Internal API endpoint") 23 - repoguardPath := flag.String("repoguard-path", "/home/git/repoguard", "Path to the repoguard binary") 24 - gitDir := flag.String("git-dir", "/home/git", "Path to the git directory") 25 - logPath := flag.String("log-path", "/home/git/log", "Path to log file") 26 - flag.Parse() 27 - 28 - resp, err := http.Get(*endpoint + "/keys") 29 - if err != nil { 30 - log.Fatalf("error fetching keys: %v", err) 31 - } 32 - defer resp.Body.Close() 33 - 34 - body, err := io.ReadAll(resp.Body) 35 - if err != nil { 36 - log.Fatalf("error reading response body: %v", err) 37 - } 38 - 39 - var data []map[string]interface{} 40 - err = json.Unmarshal(body, &data) 41 - if err != nil { 42 - log.Fatalf("error unmarshalling response body: %v", err) 43 - } 44 - 45 - fmt.Print(formatKeyData(*repoguardPath, *gitDir, *logPath, *endpoint, data)) 46 - }
-71
cmd/knotserver/main.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "net/http" 6 - 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - "tangled.sh/tangled.sh/core/jetstream" 9 - "tangled.sh/tangled.sh/core/knotserver" 10 - "tangled.sh/tangled.sh/core/knotserver/config" 11 - "tangled.sh/tangled.sh/core/knotserver/db" 12 - "tangled.sh/tangled.sh/core/log" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - 15 - _ "net/http/pprof" 16 - ) 17 - 18 - func main() { 19 - ctx := context.Background() 20 - // ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 21 - // defer stop() 22 - 23 - l := log.New("knotserver") 24 - 25 - c, err := config.Load(ctx) 26 - if err != nil { 27 - l.Error("failed to load config", "error", err) 28 - return 29 - } 30 - 31 - if c.Server.Dev { 32 - l.Info("running in dev mode, signature verification is disabled") 33 - } 34 - 35 - db, err := db.Setup(c.Server.DBPath) 36 - if err != nil { 37 - l.Error("failed to setup db", "error", err) 38 - return 39 - } 40 - 41 - e, err := rbac.NewEnforcer(c.Server.DBPath) 42 - if err != nil { 43 - l.Error("failed to setup rbac enforcer", "error", err) 44 - return 45 - } 46 - 47 - e.E.EnableAutoSave(true) 48 - 49 - jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ 50 - tangled.PublicKeyNSID, 51 - tangled.KnotMemberNSID, 52 - }, nil, l, db, true) 53 - if err != nil { 54 - l.Error("failed to setup jetstream", "error", err) 55 - } 56 - 57 - mux, err := knotserver.Setup(ctx, c, db, e, jc, l) 58 - if err != nil { 59 - l.Error("failed to setup server", "error", err) 60 - return 61 - } 62 - imux := knotserver.Internal(ctx, db, e) 63 - 64 - l.Info("starting internal server", "address", c.Server.InternalListenAddr) 65 - go http.ListenAndServe(c.Server.InternalListenAddr, imux) 66 - 67 - l.Info("starting main server", "address", c.Server.ListenAddr) 68 - l.Error("server error", "error", http.ListenAndServe(c.Server.ListenAddr, mux)) 69 - 70 - return 71 - }
+1 -1
systemd/knotserver.service
··· 4 4 5 5 6 6 [Service] 7 - ExecStart=/usr/local/bin/knotserver 7 + ExecStart=/usr/local/bin/knot server 8 8 Restart=always 9 9 User=git 10 10 WorkingDirectory=/home/git
+43
knotserver/notifier/notifier.go
··· 1 + package notifier 2 + 3 + import ( 4 + "sync" 5 + ) 6 + 7 + type Notifier struct { 8 + subscribers map[chan struct{}]struct{} 9 + mu sync.Mutex 10 + } 11 + 12 + func New() Notifier { 13 + return Notifier{ 14 + subscribers: make(map[chan struct{}]struct{}), 15 + } 16 + } 17 + 18 + func (n *Notifier) Subscribe() chan struct{} { 19 + ch := make(chan struct{}, 1) 20 + n.mu.Lock() 21 + n.subscribers[ch] = struct{}{} 22 + n.mu.Unlock() 23 + return ch 24 + } 25 + 26 + func (n *Notifier) Unsubscribe(ch chan struct{}) { 27 + n.mu.Lock() 28 + delete(n.subscribers, ch) 29 + close(ch) 30 + n.mu.Unlock() 31 + } 32 + 33 + func (n *Notifier) NotifyAll() { 34 + n.mu.Lock() 35 + for ch := range n.subscribers { 36 + select { 37 + case ch <- struct{}{}: 38 + default: 39 + // avoid blocking if channel is full 40 + } 41 + } 42 + n.mu.Unlock() 43 + }
-33
docker/docker-compose.yml
··· 1 - services: 2 - knot: 3 - build: 4 - context: .. 5 - dockerfile: docker/Dockerfile 6 - environment: 7 - KNOT_SERVER_HOSTNAME: ${KNOT_SERVER_HOSTNAME} 8 - KNOT_SERVER_SECRET: ${KNOT_SERVER_SECRET} 9 - KNOT_SERVER_DB_PATH: "/app/knotserver.db" 10 - KNOT_REPO_SCAN_PATH: "/home/git/repositories" 11 - volumes: 12 - - "./keys:/etc/ssh/keys" 13 - - "./repositories:/home/git/repositories" 14 - - "./server:/app" 15 - ports: 16 - - "2222:22" 17 - frontend: 18 - image: caddy:2-alpine 19 - command: > 20 - caddy 21 - reverse-proxy 22 - --from ${KNOT_SERVER_HOSTNAME} 23 - --to knot:5555 24 - depends_on: 25 - - knot 26 - ports: 27 - - "443:443" 28 - - "443:443/udp" 29 - volumes: 30 - - caddy_data:/data 31 - restart: always 32 - volumes: 33 - caddy_data:
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/type
··· 1 - oneshot
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/up
··· 1 - /etc/s6-overlay/scripts/create-sshd-host-keys
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/dependencies.d/base
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/type
··· 1 - longrun
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/base
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/create-sshd-host-keys
-3
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/run
··· 1 - #!/usr/bin/execlineb -P 2 - 3 - /usr/sbin/sshd -e -D
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/type
··· 1 - longrun
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/knotserver
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/sshd
-21
docker/rootfs/etc/s6-overlay/scripts/create-sshd-host-keys
··· 1 - #!/usr/bin/execlineb -P 2 - 3 - foreground { 4 - if -n { test -d /etc/ssh/keys } 5 - mkdir /etc/ssh/keys 6 - } 7 - 8 - foreground { 9 - if -n { test -f /etc/ssh/keys/ssh_host_rsa_key } 10 - ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_rsa_key -q -N "" 11 - } 12 - 13 - foreground { 14 - if -n { test -f /etc/ssh/keys/ssh_host_ecdsa_key } 15 - ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ecdsa_key -q -N "" 16 - } 17 - 18 - foreground { 19 - if -n { test -f /etc/ssh/keys/ssh_host_ed25519_key } 20 - ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ed25519_key -q -N "" 21 - }
+14
appview/cache/cache.go
··· 1 + package cache 2 + 3 + import "github.com/redis/go-redis/v9" 4 + 5 + type Cache struct { 6 + *redis.Client 7 + } 8 + 9 + func New(addr string) *Cache { 10 + rdb := redis.NewClient(&redis.Options{ 11 + Addr: addr, 12 + }) 13 + return &Cache{rdb} 14 + }
+18
nix/pkgs/sqlite-lib.nix
··· 1 + { 2 + gcc, 3 + stdenv, 4 + sqlite-lib-src, 5 + }: 6 + stdenv.mkDerivation { 7 + name = "sqlite-lib"; 8 + src = sqlite-lib-src; 9 + nativeBuildInputs = [gcc]; 10 + buildPhase = '' 11 + gcc -c sqlite3.c 12 + ar rcs libsqlite3.a sqlite3.o 13 + ranlib libsqlite3.a 14 + mkdir -p $out/include $out/lib 15 + cp *.h $out/include 16 + cp libsqlite3.a $out/lib 17 + ''; 18 + }
+20
nix/pkgs/knot.nix
··· 1 + { 2 + knot-unwrapped, 3 + makeWrapper, 4 + git, 5 + }: 6 + knot-unwrapped.overrideAttrs (after: before: { 7 + nativeBuildInputs = (before.nativeBuildInputs or []) ++ [makeWrapper]; 8 + 9 + installPhase = '' 10 + runHook preInstall 11 + 12 + mkdir -p $out/bin 13 + cp $GOPATH/bin/knot $out/bin/knot 14 + 15 + wrapProgram $out/bin/knot \ 16 + --prefix PATH : ${git}/bin 17 + 18 + runHook postInstall 19 + ''; 20 + })
-70
knotserver/db/oplog.go
··· 1 - package db 2 - 3 - import ( 4 - "fmt" 5 - 6 - "tangled.sh/tangled.sh/core/knotserver/notifier" 7 - ) 8 - 9 - type Op struct { 10 - Tid string // time based ID, easy to enumerate & monotonic 11 - Did string // did of pusher 12 - Repo string // <did/repo> fully qualified repo 13 - OldSha string // old sha of reference being updated 14 - NewSha string // new sha of reference being updated 15 - Ref string // the reference being updated 16 - } 17 - 18 - func (d *DB) InsertOp(op Op, notifier *notifier.Notifier) error { 19 - _, err := d.db.Exec( 20 - `insert into oplog (tid, did, repo, old_sha, new_sha, ref) values (?, ?, ?, ?, ?, ?)`, 21 - op.Tid, 22 - op.Did, 23 - op.Repo, 24 - op.OldSha, 25 - op.NewSha, 26 - op.Ref, 27 - ) 28 - if err != nil { 29 - return err 30 - } 31 - 32 - notifier.NotifyAll() 33 - return nil 34 - } 35 - 36 - func (d *DB) GetOps(cursor string) ([]Op, error) { 37 - whereClause := "" 38 - args := []any{} 39 - if cursor != "" { 40 - whereClause = "where tid > ?" 41 - args = append(args, cursor) 42 - } 43 - 44 - query := fmt.Sprintf(` 45 - select tid, did, repo, old_sha, new_sha, ref 46 - from oplog 47 - %s 48 - order by tid asc 49 - limit 100 50 - `, whereClause) 51 - 52 - rows, err := d.db.Query(query, args...) 53 - if err != nil { 54 - return nil, err 55 - } 56 - defer rows.Close() 57 - 58 - var ops []Op 59 - for rows.Next() { 60 - var op Op 61 - rows.Scan(&op.Tid, &op.Did, &op.Repo, &op.OldSha, &op.NewSha, &op.Ref) 62 - ops = append(ops, op) 63 - } 64 - 65 - if err := rows.Err(); err != nil { 66 - return nil, err 67 - } 68 - 69 - return ops, nil 70 - }
+9
spindle/tid.go
··· 1 + package spindle 2 + 3 + import "github.com/bluesky-social/indigo/atproto/syntax" 4 + 5 + var TIDClock = syntax.NewTIDClock(0) 6 + 7 + func TID() string { 8 + return TIDClock.Next().String() 9 + }
knotserver/notifier/notifier.go notifier/notifier.go
+23
knotclient/cursor/memory.go
··· 1 + package cursor 2 + 3 + import ( 4 + "sync" 5 + ) 6 + 7 + type MemoryStore struct { 8 + store sync.Map 9 + } 10 + 11 + func (m *MemoryStore) Set(knot string, cursor int64) { 12 + m.store.Store(knot, cursor) 13 + } 14 + 15 + func (m *MemoryStore) Get(knot string) (cursor int64) { 16 + if result, ok := m.store.Load(knot); ok { 17 + if val, ok := result.(int64); ok { 18 + return val 19 + } 20 + } 21 + 22 + return 0 23 + }
+43
knotclient/cursor/redis.go
··· 1 + package cursor 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strconv" 7 + 8 + "tangled.sh/tangled.sh/core/appview/cache" 9 + ) 10 + 11 + const ( 12 + cursorKey = "cursor:%s" 13 + ) 14 + 15 + type RedisStore struct { 16 + rdb *cache.Cache 17 + } 18 + 19 + func NewRedisCursorStore(cache *cache.Cache) RedisStore { 20 + return RedisStore{ 21 + rdb: cache, 22 + } 23 + } 24 + 25 + func (r *RedisStore) Set(knot string, cursor int64) { 26 + key := fmt.Sprintf(cursorKey, knot) 27 + r.rdb.Set(context.Background(), key, cursor, 0) 28 + } 29 + 30 + func (r *RedisStore) Get(knot string) (cursor int64) { 31 + key := fmt.Sprintf(cursorKey, knot) 32 + val, err := r.rdb.Get(context.Background(), key).Result() 33 + if err != nil { 34 + return 0 35 + } 36 + cursor, err = strconv.ParseInt(val, 10, 64) 37 + if err != nil { 38 + // TODO: log here 39 + return 0 40 + } 41 + 42 + return cursor 43 + }
+6
knotclient/cursor/store.go
··· 1 + package cursor 2 + 3 + type Store interface { 4 + Set(knot string, cursor int64) 5 + Get(knot string) (cursor int64) 6 + }
+83
knotclient/cursor/sqlite.go
··· 1 + package cursor 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + 7 + _ "github.com/mattn/go-sqlite3" 8 + ) 9 + 10 + type SqliteStore struct { 11 + db *sql.DB 12 + tableName string 13 + } 14 + 15 + type SqliteStoreOpt func(*SqliteStore) 16 + 17 + func WithTableName(name string) SqliteStoreOpt { 18 + return func(s *SqliteStore) { 19 + s.tableName = name 20 + } 21 + } 22 + 23 + func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) { 24 + db, err := sql.Open("sqlite3", dbPath) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to open sqlite database: %w", err) 27 + } 28 + 29 + store := &SqliteStore{ 30 + db: db, 31 + tableName: "cursors", 32 + } 33 + 34 + for _, o := range opts { 35 + o(store) 36 + } 37 + 38 + if err := store.init(); err != nil { 39 + return nil, err 40 + } 41 + 42 + return store, nil 43 + } 44 + 45 + func (s *SqliteStore) init() error { 46 + createTable := fmt.Sprintf(` 47 + create table if not exists %s ( 48 + knot text primary key, 49 + cursor text 50 + );`, s.tableName) 51 + _, err := s.db.Exec(createTable) 52 + return err 53 + } 54 + 55 + func (s *SqliteStore) Set(knot string, cursor int64) { 56 + query := fmt.Sprintf(` 57 + insert into %s (knot, cursor) 58 + values (?, ?) 59 + on conflict(knot) do update set cursor=excluded.cursor; 60 + `, s.tableName) 61 + 62 + _, err := s.db.Exec(query, knot, cursor) 63 + 64 + if err != nil { 65 + // TODO: log here 66 + } 67 + } 68 + 69 + func (s *SqliteStore) Get(knot string) (cursor int64) { 70 + query := fmt.Sprintf(` 71 + select cursor from %s where knot = ?; 72 + `, s.tableName) 73 + err := s.db.QueryRow(query, knot).Scan(&cursor) 74 + 75 + if err != nil { 76 + if err != sql.ErrNoRows { 77 + // TODO: log here 78 + } 79 + return 0 80 + } 81 + 82 + return cursor 83 + }
+1 -1
spindle/tid.go tid/tid.go
··· 1 - package spindle 1 + package tid 2 2 3 3 import "github.com/bluesky-social/indigo/atproto/syntax" 4 4
+44
spindle/db/known_dids.go
··· 1 + package db 2 + 3 + func (d *DB) AddDid(did string) error { 4 + _, err := d.Exec(`insert or ignore into known_dids (did) values (?)`, did) 5 + return err 6 + } 7 + 8 + func (d *DB) RemoveDid(did string) error { 9 + _, err := d.Exec(`delete from known_dids where did = ?`, did) 10 + return err 11 + } 12 + 13 + func (d *DB) GetAllDids() ([]string, error) { 14 + var dids []string 15 + 16 + rows, err := d.Query(`select did from known_dids`) 17 + if err != nil { 18 + return nil, err 19 + } 20 + defer rows.Close() 21 + 22 + for rows.Next() { 23 + var did string 24 + if err := rows.Scan(&did); err != nil { 25 + return nil, err 26 + } 27 + dids = append(dids, did) 28 + } 29 + 30 + if err := rows.Err(); err != nil { 31 + return nil, err 32 + } 33 + 34 + return dids, nil 35 + } 36 + 37 + func (d *DB) HasKnownDids() bool { 38 + var count int 39 + err := d.QueryRow(`select count(*) from known_dids`).Scan(&count) 40 + if err != nil { 41 + return false 42 + } 43 + return count > 0 44 + }
+4
lexicons/repo.json
··· 28 28 "type": "string", 29 29 "description": "knot where the repo was created" 30 30 }, 31 + "spindle": { 32 + "type": "string", 33 + "description": "CI runner to send jobs to and receive results from" 34 + }, 31 35 "description": { 32 36 "type": "string", 33 37 "format": "datetime",
+22
api/tangled/tangledspindle.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.spindle 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + SpindleNSID = "sh.tangled.spindle" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.spindle", &Spindle{}) 17 + } // 18 + // RECORDTYPE: Spindle 19 + type Spindle struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.spindle" cborgen:"$type,const=sh.tangled.spindle"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + }
+25
lexicons/spindle.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.spindle", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + } 25 +
+6
knotclient/events.go
··· 150 150 } 151 151 152 152 func (c *EventConsumer) AddSource(ctx context.Context, s EventSource) { 153 + // we are already listening to this source 154 + if _, ok := c.cfg.Sources[s]; ok { 155 + c.logger.Info("source already present", "source", s) 156 + return 157 + } 158 + 153 159 c.cfgMu.Lock() 154 160 c.cfg.Sources[s] = struct{}{} 155 161 c.wg.Add(1)
knotclient/cursor/memory.go eventconsumer/cursor/memory.go
knotclient/cursor/store.go eventconsumer/cursor/store.go
+39
eventconsumer/knot.go
··· 1 + package eventconsumer 2 + 3 + import ( 4 + "fmt" 5 + "net/url" 6 + ) 7 + 8 + type KnotSource struct { 9 + Knot string 10 + } 11 + 12 + func (k KnotSource) Key() string { 13 + return k.Knot 14 + } 15 + 16 + func (k KnotSource) Url(cursor int64, dev bool) (*url.URL, error) { 17 + scheme := "wss" 18 + if dev { 19 + scheme = "ws" 20 + } 21 + 22 + u, err := url.Parse(scheme + "://" + k.Knot + "/events") 23 + if err != nil { 24 + return nil, err 25 + } 26 + 27 + if cursor != 0 { 28 + query := url.Values{} 29 + query.Add("cursor", fmt.Sprintf("%d", cursor)) 30 + u.RawQuery = query.Encode() 31 + } 32 + return u, nil 33 + } 34 + 35 + func NewKnotSource(knot string) KnotSource { 36 + return KnotSource{ 37 + Knot: knot, 38 + } 39 + }
+39
eventconsumer/spindle.go
··· 1 + package eventconsumer 2 + 3 + import ( 4 + "fmt" 5 + "net/url" 6 + ) 7 + 8 + type SpindleSource struct { 9 + Spindle string 10 + } 11 + 12 + func (s SpindleSource) Key() string { 13 + return s.Spindle 14 + } 15 + 16 + func (s SpindleSource) Url(cursor int64, dev bool) (*url.URL, error) { 17 + scheme := "wss" 18 + if dev { 19 + scheme = "ws" 20 + } 21 + 22 + u, err := url.Parse(scheme + "://" + s.Spindle + "/events") 23 + if err != nil { 24 + return nil, err 25 + } 26 + 27 + if cursor != 0 { 28 + query := url.Values{} 29 + query.Add("cursor", fmt.Sprintf("%d", cursor)) 30 + u.RawQuery = query.Encode() 31 + } 32 + return u, nil 33 + } 34 + 35 + func NewSpindleSource(spindle string) SpindleSource { 36 + return SpindleSource{ 37 + Spindle: spindle, 38 + } 39 + }
-119
appview/pages/templates/repo/fragments/pipelineStatusSymbol.html
··· 1 - {{ define "repo/fragments/pipelineStatusSymbol" }} 2 - <div class="group relative inline-block"> 3 - {{ block "icon" $ }} {{ end }} 4 - {{ block "tooltip" $ }} {{ end }} 5 - </div> 6 - {{ end }} 7 - 8 - {{ define "icon" }} 9 - <div class="cursor-pointer"> 10 - {{ $c := .Counts }} 11 - {{ $statuses := .Statuses }} 12 - {{ $total := len $statuses }} 13 - {{ $success := index $c "success" }} 14 - {{ $allPass := eq $success $total }} 15 - 16 - {{ if $allPass }} 17 - <div class="flex gap-1 items-center"> 18 - {{ i "check" "size-4 text-green-600 dark:text-green-400 " }} 19 - <span>{{ $total }}/{{ $total }}</span> 20 - </div> 21 - {{ else }} 22 - {{ $radius := f64 8 }} 23 - {{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }} 24 - {{ $offset := 0.0 }} 25 - <div class="flex gap-1 items-center"> 26 - <svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20"> 27 - <circle cx="10" cy="10" r="{{ $radius }}" fill="none" stroke="#f3f4f633" stroke-width="2"/> 28 - 29 - {{ range $kind, $count := $c }} 30 - {{ $color := "" }} 31 - {{ if or (eq $kind "pending") (eq $kind "running") }} 32 - {{ $color = "#eab308" }} 33 - {{ else if eq $kind "success" }} 34 - {{ $color = "#10b981" }} 35 - {{ else if eq $kind "cancelled" }} 36 - {{ $color = "#6b7280" }} 37 - {{ else }} 38 - {{ $color = "#ef4444" }} 39 - {{ end }} 40 - 41 - {{ $percent := divf64 (f64 $count) (f64 $total) }} 42 - {{ $length := mulf64 $percent $circumference }} 43 - 44 - <circle 45 - cx="10" cy="10" r="{{ $radius }}" 46 - fill="none" 47 - stroke="{{ $color }}" 48 - stroke-width="2" 49 - stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}" 50 - stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}" 51 - /> 52 - {{ $offset = addf64 $offset $length }} 53 - {{ end }} 54 - </svg> 55 - <span>{{$success}}/{{ $total }}</span> 56 - </div> 57 - {{ end }} 58 - </div> 59 - {{ end }} 60 - 61 - {{ define "tooltip" }} 62 - <div class="absolute z-[9999] hidden group-hover:block bg-white dark:bg-gray-900 text-sm text-black dark:text-white rounded-md shadow p-2 w-80 top-full mt-2"> 63 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700"> 64 - {{ range $name, $all := .Statuses }} 65 - <div class="flex items-center justify-between p-1"> 66 - {{ $lastStatus := $all.Latest }} 67 - {{ $kind := $lastStatus.Status.String }} 68 - 69 - {{ $icon := "dot" }} 70 - {{ $color := "text-gray-600 dark:text-gray-500" }} 71 - {{ $text := "Failed" }} 72 - {{ $time := "" }} 73 - 74 - {{ if eq $kind "pending" }} 75 - {{ $icon = "circle-dashed" }} 76 - {{ $color = "text-yellow-600 dark:text-yellow-500" }} 77 - {{ $text = "Queued" }} 78 - {{ $time = timeFmt $lastStatus.Created }} 79 - {{ else if eq $kind "running" }} 80 - {{ $icon = "circle-dashed" }} 81 - {{ $color = "text-yellow-600 dark:text-yellow-500" }} 82 - {{ $text = "Running" }} 83 - {{ $time = timeFmt $lastStatus.Created }} 84 - {{ else if eq $kind "success" }} 85 - {{ $icon = "check" }} 86 - {{ $color = "text-green-600 dark:text-green-500" }} 87 - {{ $text = "Success" }} 88 - {{ with $all.TimeTaken }} 89 - {{ $time = durationFmt . }} 90 - {{ end }} 91 - {{ else if eq $kind "cancelled" }} 92 - {{ $icon = "circle-slash" }} 93 - {{ $color = "text-gray-600 dark:text-gray-500" }} 94 - {{ $text = "Cancelled" }} 95 - {{ with $all.TimeTaken }} 96 - {{ $time = durationFmt . }} 97 - {{ end }} 98 - {{ else }} 99 - {{ $icon = "x" }} 100 - {{ $color = "text-red-600 dark:text-red-500" }} 101 - {{ $text = "Failed" }} 102 - {{ with $all.TimeTaken }} 103 - {{ $time = durationFmt . }} 104 - {{ end }} 105 - {{ end }} 106 - 107 - <div id="left" class="flex items-center gap-2 flex-shrink-0"> 108 - {{ i $icon "size-4" $color }} 109 - {{ $name }} 110 - </div> 111 - <div id="right" class="flex items-center gap-2 flex-shrink-0"> 112 - <span class="font-bold">{{ $text }}</span> 113 - <time class="text-gray-400 dark:text-gray-600">{{ $time }}</time> 114 - </div> 115 - </div> 116 - {{ end }} 117 - </div> 118 - </div> 119 - {{ end }}
+118
appview/spindleverify/verify.go
··· 1 + package spindleverify 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "strings" 10 + "time" 11 + 12 + "tangled.sh/tangled.sh/core/appview/db" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + ) 15 + 16 + var ( 17 + FetchError = errors.New("failed to fetch owner") 18 + ) 19 + 20 + // TODO: move this to "spindleclient" or similar 21 + func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 22 + scheme := "https" 23 + if dev { 24 + scheme = "http" 25 + } 26 + 27 + url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 + req, err := http.NewRequest("GET", url, nil) 29 + if err != nil { 30 + return "", err 31 + } 32 + 33 + client := &http.Client{ 34 + Timeout: 1 * time.Second, 35 + } 36 + 37 + resp, err := client.Do(req.WithContext(ctx)) 38 + if err != nil || resp.StatusCode != 200 { 39 + return "", fmt.Errorf("failed to fetch /owner") 40 + } 41 + 42 + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 + if err != nil { 44 + return "", fmt.Errorf("failed to read /owner response: %w", err) 45 + } 46 + 47 + did := strings.TrimSpace(string(body)) 48 + if did == "" { 49 + return "", fmt.Errorf("empty DID in /owner response") 50 + } 51 + 52 + return did, nil 53 + } 54 + 55 + type OwnerMismatch struct { 56 + expected string 57 + observed string 58 + } 59 + 60 + func (e *OwnerMismatch) Error() string { 61 + return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 62 + } 63 + 64 + func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error { 65 + // begin verification 66 + observedOwner, err := fetchOwner(ctx, instance, dev) 67 + if err != nil { 68 + return fmt.Errorf("%w: %w", FetchError, err) 69 + } 70 + 71 + if observedOwner != expectedOwner { 72 + return &OwnerMismatch{ 73 + expected: expectedOwner, 74 + observed: observedOwner, 75 + } 76 + } 77 + 78 + return nil 79 + } 80 + 81 + // mark this spindle as verified in the DB and add this user as its owner 82 + func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 83 + tx, err := d.Begin() 84 + if err != nil { 85 + return 0, fmt.Errorf("failed to create txn: %w", err) 86 + } 87 + defer func() { 88 + tx.Rollback() 89 + e.E.LoadPolicy() 90 + }() 91 + 92 + // mark this spindle as verified in the db 93 + rowId, err := db.VerifySpindle( 94 + tx, 95 + db.FilterEq("owner", owner), 96 + db.FilterEq("instance", instance), 97 + ) 98 + if err != nil { 99 + return 0, fmt.Errorf("failed to write to DB: %w", err) 100 + } 101 + 102 + err = e.AddSpindleOwner(instance, owner) 103 + if err != nil { 104 + return 0, fmt.Errorf("failed to update ACL: %w", err) 105 + } 106 + 107 + err = tx.Commit() 108 + if err != nil { 109 + return 0, fmt.Errorf("failed to commit txn: %w", err) 110 + } 111 + 112 + err = e.E.SavePolicy() 113 + if err != nil { 114 + return 0, fmt.Errorf("failed to update ACL: %w", err) 115 + } 116 + 117 + return rowId, nil 118 + }
+27 -12
spindle/engine/logger.go
··· 41 41 42 42 func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 43 43 // TODO: emit stream 44 - return &jsonWriter{logger: l, kind: models.LogKindData} 44 + return &dataWriter{ 45 + logger: l, 46 + stream: stream, 47 + } 45 48 } 46 49 47 - func (l *WorkflowLogger) ControlWriter() io.Writer { 48 - return &jsonWriter{logger: l, kind: models.LogKindControl} 50 + func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer { 51 + return &controlWriter{ 52 + logger: l, 53 + idx: idx, 54 + step: step, 55 + } 49 56 } 50 57 51 - type jsonWriter struct { 58 + type dataWriter struct { 52 59 logger *WorkflowLogger 53 - kind models.LogKind 60 + stream string 54 61 } 55 62 56 - func (w *jsonWriter) Write(p []byte) (int, error) { 63 + func (w *dataWriter) Write(p []byte) (int, error) { 57 64 line := strings.TrimRight(string(p), "\r\n") 58 - 59 - entry := models.LogLine{ 60 - Kind: w.kind, 61 - Content: line, 65 + entry := models.NewDataLogLine(line, w.stream) 66 + if err := w.logger.encoder.Encode(entry); err != nil { 67 + return 0, err 62 68 } 69 + return len(p), nil 70 + } 71 + 72 + type controlWriter struct { 73 + logger *WorkflowLogger 74 + idx int 75 + step models.Step 76 + } 63 77 78 + func (w *controlWriter) Write(_ []byte) (int, error) { 79 + entry := models.NewControlLogLine(w.idx, w.step) 64 80 if err := w.logger.encoder.Encode(entry); err != nil { 65 81 return 0, err 66 82 } 67 - 68 - return len(p), nil 83 + return len(w.step.Name), nil 69 84 }
+85 -54
lexicons/pipeline.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": ["triggerMetadata", "workflows"], 12 + "required": [ 13 + "triggerMetadata", 14 + "workflows" 15 + ], 13 16 "properties": { 14 17 "triggerMetadata": { 15 18 "type": "ref", ··· 27 30 }, 28 31 "triggerMetadata": { 29 32 "type": "object", 30 - "required": ["kind", "repo"], 33 + "required": [ 34 + "kind", 35 + "repo" 36 + ], 31 37 "properties": { 32 38 "kind": { 33 39 "type": "string", 34 - "enum": ["push", "pull_request", "manual"] 40 + "enum": [ 41 + "push", 42 + "pull_request", 43 + "manual" 44 + ] 35 45 }, 36 46 "repo": { 37 47 "type": "ref", ··· 53 63 }, 54 64 "triggerRepo": { 55 65 "type": "object", 56 - "required": ["knot", "did", "repo", "defaultBranch"], 66 + "required": [ 67 + "knot", 68 + "did", 69 + "repo", 70 + "defaultBranch" 71 + ], 57 72 "properties": { 58 73 "knot": { 59 74 "type": "string" ··· 72 87 }, 73 88 "pushTriggerData": { 74 89 "type": "object", 75 - "required": ["ref", "newSha", "oldSha"], 90 + "required": [ 91 + "ref", 92 + "newSha", 93 + "oldSha" 94 + ], 76 95 "properties": { 77 96 "ref": { 78 97 "type": "string" ··· 91 110 }, 92 111 "pullRequestTriggerData": { 93 112 "type": "object", 94 - "required": ["sourceBranch", "targetBranch", "sourceSha", "action"], 113 + "required": [ 114 + "sourceBranch", 115 + "targetBranch", 116 + "sourceSha", 117 + "action" 118 + ], 95 119 "properties": { 96 120 "sourceBranch": { 97 121 "type": "string" ··· 115 139 "inputs": { 116 140 "type": "array", 117 141 "items": { 118 - "type": "object", 119 - "required": ["key", "value"], 120 - "properties": { 121 - "key": { 122 - "type": "string" 123 - }, 124 - "value": { 125 - "type": "string" 126 - } 127 - } 142 + "type": "ref", 143 + "ref": "#pair" 128 144 } 129 145 } 130 146 } 131 147 }, 132 148 "workflow": { 133 149 "type": "object", 134 - "required": ["name", "dependencies", "steps", "environment", "clone"], 150 + "required": [ 151 + "name", 152 + "dependencies", 153 + "steps", 154 + "environment", 155 + "clone" 156 + ], 135 157 "properties": { 136 158 "name": { 137 159 "type": "string" 138 160 }, 139 161 "dependencies": { 140 - "type": "ref", 141 - "ref": "#dependencies" 162 + "type": "array", 163 + "items": { 164 + "type": "ref", 165 + "ref": "#dependency" 166 + } 142 167 }, 143 168 "steps": { 144 169 "type": "array", ··· 150 175 "environment": { 151 176 "type": "array", 152 177 "items": { 153 - "type": "object", 154 - "required": ["key", "value"], 155 - "properties": { 156 - "key": { 157 - "type": "string" 158 - }, 159 - "value": { 160 - "type": "string" 161 - } 162 - } 178 + "type": "ref", 179 + "ref": "#pair" 163 180 } 164 181 }, 165 182 "clone": { ··· 168 185 } 169 186 } 170 187 }, 171 - "dependencies": { 172 - "type": "array", 173 - "items": { 174 - "type": "object", 175 - "required": ["registry", "packages"], 176 - "properties": { 177 - "registry": { 188 + "dependency": { 189 + "type": "object", 190 + "required": [ 191 + "registry", 192 + "packages" 193 + ], 194 + "properties": { 195 + "registry": { 196 + "type": "string" 197 + }, 198 + "packages": { 199 + "type": "array", 200 + "items": { 178 201 "type": "string" 179 - }, 180 - "packages": { 181 - "type": "array", 182 - "items": { 183 - "type": "string" 184 - } 185 202 } 186 203 } 187 204 } 188 205 }, 189 206 "cloneOpts": { 190 207 "type": "object", 191 - "required": ["skip", "depth", "submodules"], 208 + "required": [ 209 + "skip", 210 + "depth", 211 + "submodules" 212 + ], 192 213 "properties": { 193 214 "skip": { 194 215 "type": "boolean" ··· 203 224 }, 204 225 "step": { 205 226 "type": "object", 206 - "required": ["name", "command"], 227 + "required": [ 228 + "name", 229 + "command" 230 + ], 207 231 "properties": { 208 232 "name": { 209 233 "type": "string" ··· 214 238 "environment": { 215 239 "type": "array", 216 240 "items": { 217 - "type": "object", 218 - "required": ["key", "value"], 219 - "properties": { 220 - "key": { 221 - "type": "string" 222 - }, 223 - "value": { 224 - "type": "string" 225 - } 226 - } 241 + "type": "ref", 242 + "ref": "#pair" 227 243 } 228 244 } 229 245 } 246 + }, 247 + "pair": { 248 + "type": "object", 249 + "required": [ 250 + "key", 251 + "value" 252 + ], 253 + "properties": { 254 + "key": { 255 + "type": "string" 256 + }, 257 + "value": { 258 + "type": "string" 259 + } 260 + } 230 261 } 231 262 } 232 263 }
+1 -1
spindle/engine/envs_test.go
··· 34 34 if got == nil { 35 35 got = EnvVars{} 36 36 } 37 - assert.Equal(t, tt.want, got) 37 + assert.ElementsMatch(t, tt.want, got) 38 38 }) 39 39 } 40 40 }
+8 -1
docs/spindle/hosting.md
··· 36 36 go build -o cmd/spindle/spindle cmd/spindle/main.go 37 37 ``` 38 38 39 - 3. **Run the Spindle binary.** 39 + 3. **Create the log directory.** 40 + 41 + ```shell 42 + sudo mkdir -p /var/log/spindle 43 + sudo chown $USER:$USER -R /var/log/spindle 44 + ``` 45 + 46 + 4. **Run the Spindle binary.** 40 47 41 48 ```shell 42 49 ./cmd/spindle/spindle
+24
api/tangled/feedreaction.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.feed.reaction 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + FeedReactionNSID = "sh.tangled.feed.reaction" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.feed.reaction", &FeedReaction{}) 17 + } // 18 + // RECORDTYPE: FeedReaction 19 + type FeedReaction struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.feed.reaction" cborgen:"$type,const=sh.tangled.feed.reaction"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + Reaction string `json:"reaction" cborgen:"reaction"` 23 + Subject string `json:"subject" cborgen:"subject"` 24 + }
+34
lexicons/feed/reaction.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.feed.reaction", 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 + "reaction", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "reaction": { 23 + "type": "string", 24 + "enum": [ "👍", "👎", "😆", "🎉", "🫤", "❤️", "🚀", "👀" ] 25 + }, 26 + "createdAt": { 27 + "type": "string", 28 + "format": "datetime" 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }
-93
appview/pages/templates/knots.html
··· 1 - {{ define "title" }}knots{{ end }} 2 - {{ define "content" }} 3 - <div class="p-6"> 4 - <p class="text-xl font-bold dark:text-white">Knots</p> 5 - </div> 6 - <div class="flex flex-col"> 7 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">register a knot</h2> 8 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 9 - <p class="mb-8 dark:text-gray-300">Generate a key to initialize your knot server.</p> 10 - <form 11 - hx-post="/knots/key" 12 - class="max-w-2xl mb-8 space-y-4" 13 - hx-indicator="#generate-knot-key-spinner" 14 - > 15 - <input 16 - type="text" 17 - id="domain" 18 - name="domain" 19 - placeholder="knot.example.com" 20 - required 21 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 22 - > 23 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex items-center" type="submit"> 24 - <span>generate key</span> 25 - <span id="generate-knot-key-spinner" class="group"> 26 - {{ i "loader-circle" "pl-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 27 - </span> 28 - </button> 29 - <div id="settings-knots-error" class="error dark:text-red-400"></div> 30 - </form> 31 - </section> 32 - 33 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">my knots</h2> 34 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 35 - <div id="knots-list" class="flex flex-col gap-6 mb-8"> 36 - {{ range .Registrations }} 37 - {{ if .Registered }} 38 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 39 - <div class="flex flex-col gap-1"> 40 - <div class="inline-flex items-center gap-4"> 41 - {{ i "git-branch" "w-3 h-3 dark:text-gray-300" }} 42 - <a href="/knots/{{ .Domain }}"> 43 - <p class="font-bold dark:text-white">{{ .Domain }}</p> 44 - </a> 45 - </div> 46 - <p class="text-sm text-gray-500 dark:text-gray-400">owned by {{ .ByDid }}</p> 47 - <p class="text-sm text-gray-500 dark:text-gray-400">registered {{ template "repo/fragments/time" .Registered }}</p> 48 - </div> 49 - </div> 50 - {{ end }} 51 - {{ else }} 52 - <p class="text-sm text-gray-500 dark:text-gray-400">No knots registered</p> 53 - {{ end }} 54 - </div> 55 - </section> 56 - 57 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">pending registrations</h2> 58 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 59 - <div id="pending-knots-list" class="flex flex-col gap-6 mb-8"> 60 - {{ range .Registrations }} 61 - {{ if not .Registered }} 62 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 63 - <div class="flex flex-col gap-1"> 64 - <div class="inline-flex items-center gap-4"> 65 - <p class="font-bold dark:text-white">{{ .Domain }}</p> 66 - <div class="inline-flex items-center gap-1"> 67 - <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded"> 68 - pending 69 - </span> 70 - </div> 71 - </div> 72 - <p class="text-sm text-gray-500 dark:text-gray-400">opened by {{ .ByDid }}</p> 73 - <p class="text-sm text-gray-500 dark:text-gray-400">created {{ template "repo/fragments/time" .Created }}</p> 74 - </div> 75 - <div class="flex gap-2 items-center"> 76 - <button 77 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group" 78 - hx-post="/knots/{{ .Domain }}/init" 79 - > 80 - {{ i "square-play" "w-5 h-5" }} 81 - <span class="hidden md:inline">initialize</span> 82 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 83 - </button> 84 - </div> 85 - </div> 86 - {{ end }} 87 - {{ else }} 88 - <p class="text-sm text-gray-500 dark:text-gray-400">No pending registrations</p> 89 - {{ end }} 90 - </div> 91 - </section> 92 - </div> 93 - {{ end }}
+42
knotserver/git/cmd.go
··· 1 + package git 2 + 3 + import ( 4 + "fmt" 5 + "os/exec" 6 + ) 7 + 8 + const ( 9 + fieldSeparator = "\x1f" // ASCII Unit Separator 10 + recordSeparator = "\x1e" // ASCII Record Separator 11 + ) 12 + 13 + func (g *GitRepo) runGitCmd(command string, extraArgs ...string) ([]byte, error) { 14 + var args []string 15 + args = append(args, command) 16 + args = append(args, extraArgs...) 17 + 18 + cmd := exec.Command("git", args...) 19 + cmd.Dir = g.path 20 + 21 + out, err := cmd.Output() 22 + if err != nil { 23 + if exitErr, ok := err.(*exec.ExitError); ok { 24 + return nil, fmt.Errorf("%w, stderr: %s", err, string(exitErr.Stderr)) 25 + } 26 + return nil, err 27 + } 28 + 29 + return out, nil 30 + } 31 + 32 + func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) { 33 + return g.runGitCmd("rev-list", extraArgs...) 34 + } 35 + 36 + func (g *GitRepo) forEachRef(extraArgs ...string) ([]byte, error) { 37 + return g.runGitCmd("for-each-ref", extraArgs...) 38 + } 39 + 40 + func (g *GitRepo) revParse(extraArgs ...string) ([]byte, error) { 41 + return g.runGitCmd("rev-parse", extraArgs...) 42 + }
-11
appview/tid.go
··· 1 - package appview 2 - 3 - import ( 4 - "github.com/bluesky-social/indigo/atproto/syntax" 5 - ) 6 - 7 - var c syntax.TIDClock = syntax.NewTIDClock(0) 8 - 9 - func TID() string { 10 - return c.Next().String() 11 - }
+33 -4
avatar/src/index.js
··· 1 1 export default { 2 2 async fetch(request, env) { 3 + // Helper function to generate a color from a string 4 + const stringToColor = (str) => { 5 + let hash = 0; 6 + for (let i = 0; i < str.length; i++) { 7 + hash = str.charCodeAt(i) + ((hash << 5) - hash); 8 + } 9 + let color = "#"; 10 + for (let i = 0; i < 3; i++) { 11 + const value = (hash >> (i * 8)) & 0xff; 12 + color += ("00" + value.toString(16)).substr(-2); 13 + } 14 + return color; 15 + }; 16 + 3 17 const url = new URL(request.url); 4 18 const { pathname, searchParams } = url; 5 19 ··· 60 74 const profile = await profileResponse.json(); 61 75 const avatar = profile.avatar; 62 76 63 - if (!avatar) { 64 - return new Response(`avatar not found for ${actor}.`, { status: 404 }); 77 + let avatarUrl = profile.avatar; 78 + 79 + if (!avatarUrl) { 80 + // Generate a random color based on the actor string 81 + const bgColor = stringToColor(actor); 82 + const size = resizeToTiny ? 32 : 128; 83 + const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`; 84 + const svgData = new TextEncoder().encode(svg); 85 + 86 + response = new Response(svgData, { 87 + headers: { 88 + "Content-Type": "image/svg+xml", 89 + "Cache-Control": "public, max-age=43200", 90 + }, 91 + }); 92 + await cache.put(cacheKey, response.clone()); 93 + return response; 65 94 } 66 95 67 96 // Resize if requested 68 97 let avatarResponse; 69 98 if (resizeToTiny) { 70 - avatarResponse = await fetch(avatar, { 99 + avatarResponse = await fetch(avatarUrl, { 71 100 cf: { 72 101 image: { 73 102 width: 32, ··· 78 107 }, 79 108 }); 80 109 } else { 81 - avatarResponse = await fetch(avatar); 110 + avatarResponse = await fetch(avatarUrl); 82 111 } 83 112 84 113 if (!avatarResponse.ok) {
-48
appview/pages/templates/repo/fragments/repoActions.html
··· 1 - {{ define "repo/fragments/repoActions" }} 2 - <div class="flex items-center gap-2 z-auto"> 3 - <button 4 - id="starBtn" 5 - class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 6 - {{ if .IsStarred }} 7 - hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 8 - {{ else }} 9 - hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 10 - {{ end }} 11 - 12 - hx-trigger="click" 13 - hx-target="#starBtn" 14 - hx-swap="outerHTML" 15 - hx-disabled-elt="#starBtn" 16 - > 17 - {{ if .IsStarred }} 18 - {{ i "star" "w-4 h-4 fill-current" }} 19 - {{ else }} 20 - {{ i "star" "w-4 h-4" }} 21 - {{ end }} 22 - <span class="text-sm"> 23 - {{ .Stats.StarCount }} 24 - </span> 25 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 26 - </button> 27 - {{ if .DisableFork }} 28 - <button 29 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 30 - disabled 31 - title="Empty repositories cannot be forked" 32 - > 33 - {{ i "git-fork" "w-4 h-4" }} 34 - fork 35 - </button> 36 - {{ else }} 37 - <a 38 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 39 - hx-boost="true" 40 - href="/{{ .FullName }}/fork" 41 - > 42 - {{ i "git-fork" "w-4 h-4" }} 43 - fork 44 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 45 - </a> 46 - {{ end }} 47 - </div> 48 - {{ end }}
+3 -1
api/tangled/stateclosed.go
··· 4 4 5 5 // schema: sh.tangled.repo.issue.state.closed 6 6 7 - const () 7 + const ( 8 + RepoIssueStateClosedNSID = "sh.tangled.repo.issue.state.closed" 9 + ) 8 10 9 11 const RepoIssueStateClosed = "sh.tangled.repo.issue.state.closed"
+3 -1
api/tangled/stateopen.go
··· 4 4 5 5 // schema: sh.tangled.repo.issue.state.open 6 6 7 - const () 7 + const ( 8 + RepoIssueStateOpenNSID = "sh.tangled.repo.issue.state.open" 9 + ) 8 10 9 11 const RepoIssueStateOpen = "sh.tangled.repo.issue.state.open"
+3 -1
api/tangled/statusclosed.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.closed 6 6 7 - const () 7 + const ( 8 + RepoPullStatusClosedNSID = "sh.tangled.repo.pull.status.closed" 9 + ) 8 10 9 11 const RepoPullStatusClosed = "sh.tangled.repo.pull.status.closed"
+3 -1
api/tangled/statusmerged.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.merged 6 6 7 - const () 7 + const ( 8 + RepoPullStatusMergedNSID = "sh.tangled.repo.pull.status.merged" 9 + ) 8 10 9 11 const RepoPullStatusMerged = "sh.tangled.repo.pull.status.merged"
+3 -1
api/tangled/statusopen.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.open 6 6 7 - const () 7 + const ( 8 + RepoPullStatusOpenNSID = "sh.tangled.repo.pull.status.open" 9 + ) 8 10 9 11 const RepoPullStatusOpen = "sh.tangled.repo.pull.status.open"
+4
appview/idresolver/resolver.go
··· 111 111 112 112 return r.directory.Purge(ctx, *id) 113 113 } 114 + 115 + func (r *Resolver) Directory() identity.Directory { 116 + return r.directory 117 + }
+2 -3
appview/idresolver/resolver.go idresolver/resolver.go
··· 11 11 "github.com/bluesky-social/indigo/atproto/identity/redisdir" 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 "github.com/carlmjohnson/versioninfo" 14 - "tangled.sh/tangled.sh/core/appview/config" 15 14 ) 16 15 17 16 type Resolver struct { ··· 56 55 } 57 56 } 58 57 59 - func RedisResolver(config config.RedisConfig) (*Resolver, error) { 60 - directory, err := RedisDirectory(config.ToURL()) 58 + func RedisResolver(redisUrl string) (*Resolver, error) { 59 + directory, err := RedisDirectory(redisUrl) 61 60 if err != nil { 62 61 return nil, err 63 62 }
+30
api/tangled/reposetDefaultBranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.setDefaultBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoSetDefaultBranchNSID = "sh.tangled.repo.setDefaultBranch" 15 + ) 16 + 17 + // RepoSetDefaultBranch_Input is the input argument to a sh.tangled.repo.setDefaultBranch call. 18 + type RepoSetDefaultBranch_Input struct { 19 + DefaultBranch string `json:"defaultBranch" cborgen:"defaultBranch"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoSetDefaultBranch calls the XRPC method "sh.tangled.repo.setDefaultBranch". 24 + func RepoSetDefaultBranch(ctx context.Context, c util.LexClient, input *RepoSetDefaultBranch_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.setDefaultBranch", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
+29
lexicons/defaultBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.setDefaultBranch", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Set the default branch for a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "defaultBranch" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "defaultBranch": { 22 + "type": "string" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }
+6 -1
hook/setup.go
··· 133 133 134 134 hookContent := fmt.Sprintf(`#!/usr/bin/env bash 135 135 # AUTO GENERATED BY KNOT, DO NOT MODIFY 136 - %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" post-recieve 136 + push_options=() 137 + for ((i=0; i<GIT_PUSH_OPTION_COUNT; i++)); do 138 + option_var="GIT_PUSH_OPTION_$i" 139 + push_options+=(-push-option "${!option_var}") 140 + done 141 + %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve 137 142 `, executablePath, config.internalApi) 138 143 139 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
+14
hook/hook.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "encoding/json" 6 7 "fmt" 7 8 "net/http" 8 9 "os" ··· 11 12 "github.com/urfave/cli/v3" 12 13 ) 13 14 15 + type HookResponse struct { 16 + Messages []string `json:"messages"` 17 + } 18 + 14 19 // The hook command is nested like so: 15 20 // 16 21 // knot hook --[flags] [hook] ··· 88 93 return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 89 94 } 90 95 96 + var data HookResponse 97 + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 98 + return fmt.Errorf("failed to decode response: %w", err) 99 + } 100 + 101 + for _, message := range data.Messages { 102 + fmt.Println(message) 103 + } 104 + 91 105 return nil 92 106 }
+31
api/tangled/repoaddSecret.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.addSecret 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoAddSecretNSID = "sh.tangled.repo.addSecret" 15 + ) 16 + 17 + // RepoAddSecret_Input is the input argument to a sh.tangled.repo.addSecret call. 18 + type RepoAddSecret_Input struct { 19 + Key string `json:"key" cborgen:"key"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + Value string `json:"value" cborgen:"value"` 22 + } 23 + 24 + // RepoAddSecret calls the XRPC method "sh.tangled.repo.addSecret". 25 + func RepoAddSecret(ctx context.Context, c util.LexClient, input *RepoAddSecret_Input) error { 26 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.addSecret", nil, input, nil); err != nil { 27 + return err 28 + } 29 + 30 + return nil 31 + }
+41
api/tangled/repolistSecrets.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.listSecrets 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoListSecretsNSID = "sh.tangled.repo.listSecrets" 15 + ) 16 + 17 + // RepoListSecrets_Output is the output of a sh.tangled.repo.listSecrets call. 18 + type RepoListSecrets_Output struct { 19 + Secrets []*RepoListSecrets_Secret `json:"secrets" cborgen:"secrets"` 20 + } 21 + 22 + // RepoListSecrets_Secret is a "secret" in the sh.tangled.repo.listSecrets schema. 23 + type RepoListSecrets_Secret struct { 24 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 25 + CreatedBy string `json:"createdBy" cborgen:"createdBy"` 26 + Key string `json:"key" cborgen:"key"` 27 + Repo string `json:"repo" cborgen:"repo"` 28 + } 29 + 30 + // RepoListSecrets calls the XRPC method "sh.tangled.repo.listSecrets". 31 + func RepoListSecrets(ctx context.Context, c util.LexClient, repo string) (*RepoListSecrets_Output, error) { 32 + var out RepoListSecrets_Output 33 + 34 + params := map[string]interface{}{} 35 + params["repo"] = repo 36 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.listSecrets", params, nil, &out); err != nil { 37 + return nil, err 38 + } 39 + 40 + return &out, nil 41 + }
+30
api/tangled/reporemoveSecret.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.removeSecret 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoRemoveSecretNSID = "sh.tangled.repo.removeSecret" 15 + ) 16 + 17 + // RepoRemoveSecret_Input is the input argument to a sh.tangled.repo.removeSecret call. 18 + type RepoRemoveSecret_Input struct { 19 + Key string `json:"key" cborgen:"key"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoRemoveSecret calls the XRPC method "sh.tangled.repo.removeSecret". 24 + func RepoRemoveSecret(ctx context.Context, c util.LexClient, input *RepoRemoveSecret_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.removeSecret", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
+37
lexicons/addSecret.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.addSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Add a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key", 15 + "value" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "key": { 23 + "type": "string", 24 + "maxLength": 50, 25 + "minLength": 1 26 + }, 27 + "value": { 28 + "type": "string", 29 + "maxLength": 200, 30 + "minLength": 1 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+67
lexicons/listSecrets.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.listSecrets", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": [ 10 + "repo" 11 + ], 12 + "properties": { 13 + "repo": { 14 + "type": "string", 15 + "format": "at-uri" 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": [ 24 + "secrets" 25 + ], 26 + "properties": { 27 + "secrets": { 28 + "type": "array", 29 + "items": { 30 + "type": "ref", 31 + "ref": "#secret" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }, 38 + "secret": { 39 + "type": "object", 40 + "required": [ 41 + "repo", 42 + "key", 43 + "createdAt", 44 + "createdBy" 45 + ], 46 + "properties": { 47 + "repo": { 48 + "type": "string", 49 + "format": "at-uri" 50 + }, 51 + "key": { 52 + "type": "string", 53 + "maxLength": 50, 54 + "minLength": 1 55 + }, 56 + "createdAt": { 57 + "type": "string", 58 + "format": "datetime" 59 + }, 60 + "createdBy": { 61 + "type": "string", 62 + "format": "did" 63 + } 64 + } 65 + } 66 + } 67 + }
+31
lexicons/removeSecret.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.removeSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Remove a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "key": { 22 + "type": "string", 23 + "maxLength": 50, 24 + "minLength": 1 25 + } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+25
spindle/motd
··· 1 + **** 2 + *** *** 3 + *** ** ****** ** 4 + ** * ***** 5 + * ** ** 6 + * * * *************** 7 + ** ** *# ** 8 + * ** ** *** ** 9 + * * ** ** * ****** 10 + * ** ** * ** * * 11 + ** ** *** ** ** * 12 + ** ** * ** * * 13 + ** **** ** * * 14 + ** *** ** ** ** 15 + *** ** ***** 16 + ******************** 17 + ** 18 + * 19 + #************** 20 + ** 21 + ******** 22 + 23 + This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle 24 + 25 + Most API routes are under /xrpc/
+2 -2
appview/pages/markup/camo.go
··· 9 9 "github.com/yuin/goldmark/ast" 10 10 ) 11 11 12 - func generateCamoURL(baseURL, secret, imageURL string) string { 12 + func GenerateCamoURL(baseURL, secret, imageURL string) string { 13 13 h := hmac.New(sha256.New, []byte(secret)) 14 14 h.Write([]byte(imageURL)) 15 15 signature := hex.EncodeToString(h.Sum(nil)) ··· 24 24 } 25 25 26 26 if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 27 - return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 27 + return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 28 28 } 29 29 30 30 return dst
+104
appview/signup/requests.go
··· 1 + package signup 2 + 3 + // We have this extra code here for now since the xrpcclient package 4 + // only supports OAuth'd requests; these are unauthenticated or use PDS admin auth. 5 + 6 + import ( 7 + "bytes" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "net/http" 12 + "net/url" 13 + ) 14 + 15 + // makePdsRequest is a helper method to make requests to the PDS service 16 + func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) { 17 + jsonData, err := json.Marshal(body) 18 + if err != nil { 19 + return nil, err 20 + } 21 + 22 + url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint) 23 + req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData)) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + req.Header.Set("Content-Type", "application/json") 29 + 30 + if useAuth { 31 + req.SetBasicAuth("admin", s.config.Pds.AdminSecret) 32 + } 33 + 34 + return http.DefaultClient.Do(req) 35 + } 36 + 37 + // handlePdsError processes error responses from the PDS service 38 + func (s *Signup) handlePdsError(resp *http.Response, action string) error { 39 + var errorResp struct { 40 + Error string `json:"error"` 41 + Message string `json:"message"` 42 + } 43 + 44 + respBody, _ := io.ReadAll(resp.Body) 45 + if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" { 46 + return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message) 47 + } 48 + 49 + // Fallback if we couldn't parse the error 50 + return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode) 51 + } 52 + 53 + func (s *Signup) inviteCodeRequest() (string, error) { 54 + body := map[string]any{"useCount": 1} 55 + 56 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true) 57 + if err != nil { 58 + return "", err 59 + } 60 + defer resp.Body.Close() 61 + 62 + if resp.StatusCode != http.StatusOK { 63 + return "", s.handlePdsError(resp, "create invite code") 64 + } 65 + 66 + var result map[string]string 67 + json.NewDecoder(resp.Body).Decode(&result) 68 + return result["code"], nil 69 + } 70 + 71 + func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) { 72 + parsedURL, err := url.Parse(s.config.Pds.Host) 73 + if err != nil { 74 + return "", fmt.Errorf("invalid PDS host URL: %w", err) 75 + } 76 + 77 + pdsDomain := parsedURL.Hostname() 78 + 79 + body := map[string]string{ 80 + "email": email, 81 + "handle": fmt.Sprintf("%s.%s", username, pdsDomain), 82 + "password": password, 83 + "inviteCode": code, 84 + } 85 + 86 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false) 87 + if err != nil { 88 + return "", err 89 + } 90 + defer resp.Body.Close() 91 + 92 + if resp.StatusCode != http.StatusOK { 93 + return "", s.handlePdsError(resp, "create account") 94 + } 95 + 96 + var result struct { 97 + DID string `json:"did"` 98 + } 99 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 100 + return "", fmt.Errorf("failed to decode create account response: %w", err) 101 + } 102 + 103 + return result.DID, nil 104 + }
lexicons/addSecret.json lexicons/repo/addSecret.json
lexicons/artifact.json lexicons/repo/artifact.json
lexicons/defaultBranch.json lexicons/repo/defaultBranch.json
lexicons/listSecrets.json lexicons/repo/listSecrets.json
lexicons/removeSecret.json lexicons/repo/removeSecret.json
lexicons/spindle.json lexicons/spindle/spindle.json
+25
api/tangled/repocollaborator.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.collaborator 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + RepoCollaboratorNSID = "sh.tangled.repo.collaborator" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{}) 17 + } // 18 + // RECORDTYPE: RepoCollaborator 19 + type RepoCollaborator struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + // repo: repo to add this user to 23 + Repo string `json:"repo" cborgen:"repo"` 24 + Subject string `json:"subject" cborgen:"subject"` 25 + }
+36
lexicons/repo/collaborator.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.collaborator", 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 + "repo", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "did" 21 + }, 22 + "repo": { 23 + "type": "string", 24 + "description": "repo to add this user to", 25 + "format": "at-uri" 26 + }, 27 + "createdAt": { 28 + "type": "string", 29 + "format": "datetime" 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 +
+25
api/tangled/tangledstring.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.string 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + StringNSID = "sh.tangled.string" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.string", &String{}) 17 + } // 18 + // RECORDTYPE: String 19 + type String struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"` 21 + Contents string `json:"contents" cborgen:"contents"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Description string `json:"description" cborgen:"description"` 24 + Filename string `json:"filename" cborgen:"filename"` 25 + }
+40
lexicons/string/string.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.string", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "filename", 14 + "description", 15 + "createdAt", 16 + "contents" 17 + ], 18 + "properties": { 19 + "filename": { 20 + "type": "string", 21 + "maxGraphemes": 140, 22 + "minGraphemes": 1 23 + }, 24 + "description": { 25 + "type": "string", 26 + "maxGraphemes": 280 27 + }, 28 + "createdAt": { 29 + "type": "string", 30 + "format": "datetime" 31 + }, 32 + "contents": { 33 + "type": "string", 34 + "minGraphemes": 1 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+2 -3
nix/pkgs/spindle.nix
··· 2 2 buildGoApplication, 3 3 modules, 4 4 sqlite-lib, 5 - gitignoreSource, 5 + src, 6 6 }: 7 7 buildGoApplication { 8 8 pname = "spindle"; 9 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13
+59
spindle/db/member.go
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type SpindleMember struct { 10 + Id int 11 + Did syntax.DID // owner of the record 12 + Rkey string // rkey of the record 13 + Instance string 14 + Subject syntax.DID // the member being added 15 + Created time.Time 16 + } 17 + 18 + func AddSpindleMember(db *DB, member SpindleMember) error { 19 + _, err := db.Exec( 20 + `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 21 + member.Did, 22 + member.Rkey, 23 + member.Instance, 24 + member.Subject, 25 + ) 26 + return err 27 + } 28 + 29 + func RemoveSpindleMember(db *DB, owner_did, rkey string) error { 30 + _, err := db.Exec( 31 + "delete from spindle_members where did = ? and rkey = ?", 32 + owner_did, 33 + rkey, 34 + ) 35 + return err 36 + } 37 + 38 + func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) { 39 + query := 40 + `select id, did, rkey, instance, subject, created 41 + from spindle_members 42 + where did = ? and rkey = ?` 43 + 44 + var member SpindleMember 45 + var createdAt string 46 + err := db.QueryRow(query, did, rkey).Scan( 47 + &member.Id, 48 + &member.Did, 49 + &member.Rkey, 50 + &member.Instance, 51 + &member.Subject, 52 + &createdAt, 53 + ) 54 + if err != nil { 55 + return nil, err 56 + } 57 + 58 + return &member, nil 59 + }
+1 -1
spindle/secrets/openbao.go
··· 132 132 return ErrKeyNotFound 133 133 } 134 134 135 - err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath) 135 + err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath) 136 136 if err != nil { 137 137 return fmt.Errorf("failed to delete secret from openbao: %w", err) 138 138 }
-62
appview/db/migrations/20250305_113405.sql
··· 1 - -- Simplified SQLite Database Migration Script for Issues and Comments 2 - 3 - -- Migration for issues table 4 - CREATE TABLE issues_new ( 5 - id integer primary key autoincrement, 6 - owner_did text not null, 7 - repo_at text not null, 8 - issue_id integer not null, 9 - title text not null, 10 - body text not null, 11 - open integer not null default 1, 12 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 13 - issue_at text, 14 - unique(repo_at, issue_id), 15 - foreign key (repo_at) references repos(at_uri) on delete cascade 16 - ); 17 - 18 - -- Migrate data to new issues table 19 - INSERT INTO issues_new ( 20 - id, owner_did, repo_at, issue_id, 21 - title, body, open, created, issue_at 22 - ) 23 - SELECT 24 - id, owner_did, repo_at, issue_id, 25 - title, body, open, created, issue_at 26 - FROM issues; 27 - 28 - -- Drop old issues table 29 - DROP TABLE issues; 30 - 31 - -- Rename new issues table 32 - ALTER TABLE issues_new RENAME TO issues; 33 - 34 - -- Migration for comments table 35 - CREATE TABLE comments_new ( 36 - id integer primary key autoincrement, 37 - owner_did text not null, 38 - issue_id integer not null, 39 - repo_at text not null, 40 - comment_id integer not null, 41 - comment_at text not null, 42 - body text not null, 43 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 - unique(issue_id, comment_id), 45 - foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade 46 - ); 47 - 48 - -- Migrate data to new comments table 49 - INSERT INTO comments_new ( 50 - id, owner_did, issue_id, repo_at, 51 - comment_id, comment_at, body, created 52 - ) 53 - SELECT 54 - id, owner_did, issue_id, repo_at, 55 - comment_id, comment_at, body, created 56 - FROM comments; 57 - 58 - -- Drop old comments table 59 - DROP TABLE comments; 60 - 61 - -- Rename new comments table 62 - ALTER TABLE comments_new RENAME TO comments;
-66
appview/db/migrations/validate.sql
··· 1 - -- Validation Queries for Database Migration 2 - 3 - -- 1. Verify Issues Table Structure 4 - PRAGMA table_info(issues); 5 - 6 - -- 2. Verify Comments Table Structure 7 - PRAGMA table_info(comments); 8 - 9 - -- 3. Check Total Row Count Consistency 10 - SELECT 11 - 'Issues Row Count' AS check_type, 12 - (SELECT COUNT(*) FROM issues) AS row_count 13 - UNION ALL 14 - SELECT 15 - 'Comments Row Count' AS check_type, 16 - (SELECT COUNT(*) FROM comments) AS row_count; 17 - 18 - -- 4. Verify Unique Constraint on Issues 19 - SELECT 20 - repo_at, 21 - issue_id, 22 - COUNT(*) as duplicate_count 23 - FROM issues 24 - GROUP BY repo_at, issue_id 25 - HAVING duplicate_count > 1; 26 - 27 - -- 5. Verify Foreign Key Integrity for Comments 28 - SELECT 29 - 'Orphaned Comments' AS check_type, 30 - COUNT(*) AS orphaned_count 31 - FROM comments c 32 - LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id 33 - WHERE i.id IS NULL; 34 - 35 - -- 6. Check Foreign Key Constraint 36 - PRAGMA foreign_key_list(comments); 37 - 38 - -- 7. Sample Data Integrity Check 39 - SELECT 40 - 'Sample Issues' AS check_type, 41 - repo_at, 42 - issue_id, 43 - title, 44 - created 45 - FROM issues 46 - LIMIT 5; 47 - 48 - -- 8. Sample Comments Data Integrity Check 49 - SELECT 50 - 'Sample Comments' AS check_type, 51 - repo_at, 52 - issue_id, 53 - comment_id, 54 - body, 55 - created 56 - FROM comments 57 - LIMIT 5; 58 - 59 - -- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness) 60 - SELECT 61 - issue_id, 62 - comment_id, 63 - COUNT(*) as duplicate_count 64 - FROM comments 65 - GROUP BY issue_id, comment_id 66 - HAVING duplicate_count > 1;
+14
nix/modules/appview.nix
··· 27 27 default = "00000000000000000000000000000000"; 28 28 description = "Cookie secret"; 29 29 }; 30 + environmentFile = mkOption { 31 + type = with types; nullOr path; 32 + default = null; 33 + example = "/etc/tangled-appview.env"; 34 + description = '' 35 + Additional environment file as defined in {manpage}`systemd.exec(5)`. 36 + 37 + Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be 38 + passed to the service without makeing them world readable in the 39 + nix store. 40 + 41 + ''; 42 + }; 30 43 }; 31 44 }; 32 45 ··· 39 52 ListenStream = "0.0.0.0:${toString cfg.port}"; 40 53 ExecStart = "${cfg.package}/bin/appview"; 41 54 Restart = "always"; 55 + EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 42 56 }; 43 57 44 58 environment = {
+2 -1
.gitignore
··· 16 16 *.rdb 17 17 .envrc 18 18 # Created if following hacking.md 19 - genjwks.out 19 + genjwks.out 20 + /nix/vm-data
+1 -1
.air/appview.toml
··· 5 5 6 6 exclude_regex = [".*_templ.go"] 7 7 include_ext = ["go", "templ", "html", "css"] 8 - exclude_dir = ["target", "atrium"] 8 + exclude_dir = ["target", "atrium", "nix"]
+1 -1
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
··· 19 19 {{ $color = "text-gray-600 dark:text-gray-500" }} 20 20 {{ else if eq $kind "timeout" }} 21 21 {{ $icon = "clock-alert" }} 22 - {{ $color = "text-orange-400 dark:text-orange-300" }} 22 + {{ $color = "text-orange-400 dark:text-orange-500" }} 23 23 {{ else }} 24 24 {{ $icon = "x" }} 25 25 {{ $color = "text-red-600 dark:text-red-500" }}
+1 -1
tailwind.config.js
··· 36 36 css: { 37 37 maxWidth: "none", 38 38 pre: { 39 - "@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {}, 39 + "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {}, 40 40 }, 41 41 code: { 42 42 "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
+1 -1
spindle/secrets/sqlite.go
··· 24 24 } 25 25 26 26 func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) { 27 - db, err := sql.Open("sqlite3", dbPath) 27 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 28 28 if err != nil { 29 29 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 30 30 }
+14 -9
knotserver/db/init.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 } 12 13 13 14 func Setup(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 15 + // https://github.com/mattn/go-sqlite3#connection-string 16 + opts := []string{ 17 + "_foreign_keys=1", 18 + "_journal_mode=WAL", 19 + "_synchronous=NORMAL", 20 + "_auto_vacuum=incremental", 21 + } 22 + 23 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 15 24 if err != nil { 16 25 return nil, err 17 26 } 18 27 19 - _, err = db.Exec(` 20 - pragma journal_mode = WAL; 21 - pragma synchronous = normal; 22 - pragma temp_store = memory; 23 - pragma mmap_size = 30000000000; 24 - pragma page_size = 32768; 25 - pragma auto_vacuum = incremental; 26 - pragma busy_timeout = 5000; 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 27 31 32 + _, err = db.Exec(` 28 33 create table if not exists known_dids ( 29 34 did text primary key 30 35 );
+14 -9
spindle/db/db.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 } 12 13 13 14 func Make(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 15 + // https://github.com/mattn/go-sqlite3#connection-string 16 + opts := []string{ 17 + "_foreign_keys=1", 18 + "_journal_mode=WAL", 19 + "_synchronous=NORMAL", 20 + "_auto_vacuum=incremental", 21 + } 22 + 23 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 15 24 if err != nil { 16 25 return nil, err 17 26 } 18 27 19 - _, err = db.Exec(` 20 - pragma journal_mode = WAL; 21 - pragma synchronous = normal; 22 - pragma temp_store = memory; 23 - pragma mmap_size = 30000000000; 24 - pragma page_size = 32768; 25 - pragma auto_vacuum = incremental; 26 - pragma busy_timeout = 5000; 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 27 31 32 + _, err = db.Exec(` 28 33 create table if not exists _jetstream ( 29 34 id integer primary key autoincrement, 30 35 last_time_us integer not null
+12
.prettierrc.json
··· 1 + { 2 + "overrides": [ 3 + { 4 + "files": ["*.html"], 5 + "options": { 6 + "parser": "go-template" 7 + } 8 + } 9 + ], 10 + "bracketSameLine": true, 11 + "htmlWhitespaceSensitivity": "ignore" 12 + }
-16
.zed/settings.json
··· 1 - // Folder-specific settings 2 - // 3 - // For a full list of overridable settings, and general information on folder-specific settings, 4 - // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 - { 6 - "languages": { 7 - "HTML": { 8 - "prettier": { 9 - "format_on_save": false, 10 - "allowed": true, 11 - "parser": "go-template", 12 - "plugins": ["prettier-plugin-go-template"] 13 - } 14 - } 15 - } 16 - }
+37 -83
appview/pages/templates/layouts/footer.html
··· 1 1 {{ define "layouts/footer" }} 2 - <div 3 - class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 4 - <div class="container mx-auto max-w-7xl px-4"> 5 - <div 6 - class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 7 - <div class="mb-4 md:mb-0"> 8 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 9 - tangled 10 - <sub>alpha</sub> 11 - </a> 12 - </div> 13 - 14 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 15 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 16 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 17 - <div 18 - class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 19 - <div class="flex flex-col gap-1"> 20 - <div class="{{ $headerStyle }}">legal</div> 21 - <a href="/terms" class="{{ $linkStyle }}"> 22 - {{ i "file-text" $iconStyle }} terms of service 23 - </a> 24 - <a href="/privacy" class="{{ $linkStyle }}"> 25 - {{ i "shield" $iconStyle }} privacy policy 26 - </a> 27 - </div> 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 28 10 29 - <div class="flex flex-col gap-1"> 30 - <div class="{{ $headerStyle }}">resources</div> 31 - <a 32 - href="https://blog.tangled.sh" 33 - class="{{ $linkStyle }}" 34 - target="_blank" 35 - rel="noopener noreferrer"> 36 - {{ i "book-open" $iconStyle }} blog 37 - </a> 38 - <a 39 - href="https://tangled.sh/@tangled.sh/core/tree/master/docs" 40 - class="{{ $linkStyle }}"> 41 - {{ i "book" $iconStyle }} docs 42 - </a> 43 - <a 44 - href="https://tangled.sh/@tangled.sh/core" 45 - class="{{ $linkStyle }}"> 46 - {{ i "code" $iconStyle }} source 47 - </a> 48 - </div> 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 + </div> 49 20 50 - <div class="flex flex-col gap-1"> 51 - <div class="{{ $headerStyle }}">social</div> 52 - <a 53 - href="https://chat.tangled.sh" 54 - class="{{ $linkStyle }}" 55 - target="_blank" 56 - rel="noopener noreferrer"> 57 - {{ i "message-circle" $iconStyle }} discord 58 - </a> 59 - <a 60 - href="https://web.libera.chat/#tangled" 61 - class="{{ $linkStyle }}" 62 - target="_blank" 63 - rel="noopener noreferrer"> 64 - {{ i "hash" $iconStyle }} irc 65 - </a> 66 - <a 67 - href="https://bsky.app/profile/tangled.sh" 68 - class="{{ $linkStyle }}" 69 - target="_blank" 70 - rel="noopener noreferrer"> 71 - {{ template "user/fragments/bluesky" $iconStyle }} bluesky 72 - </a> 73 - </div> 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 74 27 75 - <div class="flex flex-col gap-1"> 76 - <div class="{{ $headerStyle }}">contact</div> 77 - <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}"> 78 - {{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh 79 - </a> 80 - <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}"> 81 - {{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh 82 - </a> 83 - </div> 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 84 33 </div> 85 34 86 - <div class="text-center lg:text-right flex-shrink-0"> 87 - <div class="text-xs"> 88 - &copy; 2025 Tangled Labs Oy. All rights reserved. 89 - </div> 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 90 39 </div> 91 40 </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 92 45 </div> 93 46 </div> 47 + </div> 94 48 {{ end }}
+13 -27
appview/pages/templates/repo/fragments/artifact.html
··· 1 1 {{ define "repo/fragments/artifact" }} 2 - {{ $unique := .Artifact.BlobCid.String }} 3 - <div 4 - id="artifact-{{ $unique }}" 5 - class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 6 - <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 7 - {{ i "box" "w-4 h-4" }} 8 - <a 9 - href="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/download/{{ .Artifact.Name | urlquery }}" 10 - class="no-underline hover:no-underline"> 11 - {{ .Artifact.Name }} 12 - </a> 13 - <span class="text-gray-500 dark:text-gray-400 pl-2 text-sm"> 14 - {{ byteFmt .Artifact.Size }} 15 - </span> 16 - </div> 2 + {{ $unique := .Artifact.BlobCid.String }} 3 + <div id="artifact-{{ $unique }}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 4 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 5 + {{ i "box" "w-4 h-4" }} 6 + <a href="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/download/{{ .Artifact.Name | urlquery }}" class="no-underline hover:no-underline"> 7 + {{ .Artifact.Name }} 8 + </a> 9 + <span class="text-gray-500 dark:text-gray-400 pl-2 text-sm">{{ byteFmt .Artifact.Size }}</span> 10 + </div> 17 11 18 - <div 19 - id="right-side" 20 - class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm"> 21 - <span class="hidden md:inline"> 22 - {{ template "repo/fragments/time" .Artifact.CreatedAt }} 23 - </span> 24 - <span class=" md:hidden"> 25 - {{ template "repo/fragments/shortTime" .Artifact.CreatedAt }} 26 - </span> 12 + <div id="right-side" class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm"> 13 + <span class="hidden md:inline">{{ template "repo/fragments/time" .Artifact.CreatedAt }}</span> 14 + <span class=" md:hidden">{{ template "repo/fragments/shortTime" .Artifact.CreatedAt }}</span> 27 15 28 16 <span class="select-none after:content-['·'] hidden md:inline"></span> 29 - <span class="truncate max-w-[100px] hidden md:inline"> 30 - {{ .Artifact.MimeType }} 31 - </span> 17 + <span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span> 32 18 33 19 {{ if and .LoggedInUser (eq .LoggedInUser.Did .Artifact.Did) }} 34 20 <button
+6 -14
appview/pages/templates/repo/fragments/diffOpts.html
··· 1 1 {{ define "repo/fragments/diffOpts" }} 2 - <section 3 - 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"> 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"> 4 3 <strong class="text-sm uppercase dark:text-gray-200">options</strong> 5 4 {{ $active := "unified" }} 6 5 {{ if .Split }} 7 6 {{ $active = "split" }} 8 7 {{ end }} 9 8 {{ $values := list "unified" "split" }} 10 - {{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} 11 - {{ end }} 9 + {{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }} 12 10 </section> 13 11 {{ end }} 14 12 ··· 16 14 {{ $name := .Name }} 17 15 {{ $all := .Values }} 18 16 {{ $active := .Active }} 19 - <div 20 - class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 17 + <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 21 18 {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 22 19 {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 23 20 {{ range $index, $value := $all }} 24 21 {{ $isActive := eq $value $active }} 25 - <a 26 - href="?{{ $name }}={{ $value }}" 27 - class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} 28 - {{ $activeTab }} 29 - {{ else }} 30 - {{ $inactiveTab }} 31 - {{ end }}"> 32 - {{ $value }} 22 + <a href="?{{ $name }}={{ $value }}" 23 + class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 24 + {{ $value }} 33 25 </a> 34 26 {{ end }} 35 27 </div>
+5 -16
appview/pages/templates/repo/fragments/diffStatPill.html
··· 1 1 {{ define "repo/fragments/diffStatPill" }} 2 2 <div class="flex items-center font-mono text-sm"> 3 3 {{ if and .Insertions .Deletions }} 4 - <span 5 - class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400"> 6 - +{{ .Insertions }} 7 - </span> 8 - <span 9 - class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400"> 10 - -{{ .Deletions }} 11 - </span> 4 + <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 5 + <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 12 6 {{ else if .Insertions }} 13 - <span 14 - class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400"> 15 - +{{ .Insertions }} 16 - </span> 7 + <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 17 8 {{ else if .Deletions }} 18 - <span 19 - class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400"> 20 - -{{ .Deletions }} 21 - </span> 9 + <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 22 10 {{ end }} 23 11 </div> 24 12 {{ end }} 13 +
+24 -18
appview/pages/templates/repo/fragments/reactionsPopUp.html
··· 1 1 {{ define "repo/fragments/reactionsPopUp" }} 2 - <details id="reactionsPopUp" class="relative inline-block"> 3 - <summary 4 - class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700 2 + <details 3 + id="reactionsPopUp" 4 + class="relative inline-block" 5 + > 6 + <summary 7 + class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700 5 8 hover:bg-gray-50 6 9 hover:border-gray-300 7 10 dark:hover:bg-gray-700 8 11 dark:hover:border-gray-600 9 - cursor-pointer list-none"> 10 - {{ i "smile" "size-4" }} 11 - </summary> 12 - <div 13 - class="absolute flex left-0 z-10 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg"> 14 - {{ range $kind := . }} 15 - <button 16 - id="reactBtn-{{ $kind }}" 17 - class="size-12 hover:bg-gray-100 dark:hover:bg-gray-700" 18 - hx-on:click="this.parentElement.parentElement.removeAttribute('open')"> 19 - {{ $kind }} 20 - </button> 21 - {{ end }} 22 - </div> 23 - </details> 12 + cursor-pointer list-none" 13 + > 14 + {{ i "smile" "size-4" }} 15 + </summary> 16 + <div 17 + class="absolute flex left-0 z-10 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg" 18 + > 19 + {{ range $kind := . }} 20 + <button 21 + id="reactBtn-{{ $kind }}" 22 + class="size-12 hover:bg-gray-100 dark:hover:bg-gray-700" 23 + hx-on:click="this.parentElement.parentElement.removeAttribute('open')" 24 + > 25 + {{ $kind }} 26 + </button> 27 + {{ end }} 28 + </div> 29 + </details> 24 30 {{ end }}
+23 -22
appview/pages/templates/repo/fragments/repoStar.html
··· 1 1 {{ define "repo/fragments/repoStar" }} 2 - <button 3 - id="starBtn" 4 - class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 5 - {{ if .IsStarred }} 6 - hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 7 - {{ else }} 8 - hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 9 - {{ end }} 2 + <button 3 + id="starBtn" 4 + class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 5 + {{ if .IsStarred }} 6 + hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 7 + {{ else }} 8 + hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 9 + {{ end }} 10 10 11 - hx-trigger="click" 12 - hx-target="this" 13 - hx-swap="outerHTML" 14 - hx-disabled-elt="#starBtn"> 15 - {{ if .IsStarred }} 16 - {{ i "star" "w-4 h-4 fill-current" }} 17 - {{ else }} 18 - {{ i "star" "w-4 h-4" }} 19 - {{ end }} 20 - <span class="text-sm"> 21 - {{ .Stats.StarCount }} 22 - </span> 23 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 24 - </button> 11 + hx-trigger="click" 12 + hx-target="this" 13 + hx-swap="outerHTML" 14 + hx-disabled-elt="#starBtn" 15 + > 16 + {{ if .IsStarred }} 17 + {{ i "star" "w-4 h-4 fill-current" }} 18 + {{ else }} 19 + {{ i "star" "w-4 h-4" }} 20 + {{ end }} 21 + <span class="text-sm"> 22 + {{ .Stats.StarCount }} 23 + </span> 24 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 25 + </button> 25 26 {{ end }}
+58 -113
appview/pages/templates/repo/fragments/splitDiff.html
··· 1 1 {{ define "repo/fragments/splitDiff" }} 2 - {{ $name := .Id }} 3 - {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 - {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 - {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 7 - {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 - {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 - {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 10 - {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 11 - {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 12 - <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700"> 13 - <pre 14 - class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}} 15 - <div 16 - class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center"> 17 - &middot;&middot;&middot; 18 - </div> 19 - {{- range .LeftLines -}} 20 - {{- if .IsEmpty -}} 21 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 22 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"> 23 - <span aria-hidden="true" class="invisible"> 24 - {{ .LineNumber }} 25 - </span> 26 - </div> 27 - <div class="{{ $opStyle }}"> 28 - <span aria-hidden="true" class="invisible">{{ .Op.String }}</span> 29 - </div> 30 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 31 - </div> 32 - {{- else if eq .Op.String "-" -}} 33 - <div 34 - class="{{ $delStyle }} {{ $containerStyle }}" 35 - id="{{ $name }}-O{{ .LineNumber }}"> 36 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"> 37 - <a 38 - class="{{ $linkStyle }}" 39 - href="#{{ $name }}-O{{ .LineNumber }}"> 40 - {{ .LineNumber }} 41 - </a> 42 - </div> 43 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 44 - <div class="px-2">{{ .Content }}</div> 45 - </div> 46 - {{- else if eq .Op.String " " -}} 47 - <div 48 - class="{{ $ctxStyle }} {{ $containerStyle }}" 49 - id="{{ $name }}-O{{ .LineNumber }}"> 50 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"> 51 - <a 52 - class="{{ $linkStyle }}" 53 - href="#{{ $name }}-O{{ .LineNumber }}"> 54 - {{ .LineNumber }} 55 - </a> 56 - </div> 57 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 58 - <div class="px-2">{{ .Content }}</div> 59 - </div> 60 - {{- end -}} 61 - {{- end -}} 62 - {{- end -}} 63 - </div></div></pre> 2 + {{ $name := .Id }} 3 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 + {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 + {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 7 + {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 + {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 + {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 10 + {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 11 + {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 12 + <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700"> 13 + <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 14 + {{- range .LeftLines -}} 15 + {{- if .IsEmpty -}} 16 + <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 18 + <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 19 + <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 20 + </div> 21 + {{- else if eq .Op.String "-" -}} 22 + <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 + <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 24 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 25 + <div class="px-2">{{ .Content }}</div> 26 + </div> 27 + {{- else if eq .Op.String " " -}} 28 + <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 + <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 30 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 31 + <div class="px-2">{{ .Content }}</div> 32 + </div> 33 + {{- end -}} 34 + {{- end -}} 35 + {{- end -}}</div></div></pre> 64 36 65 - <pre 66 - class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}} 67 - <div 68 - class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center"> 69 - &middot;&middot;&middot; 70 - </div> 71 - {{- range .RightLines -}} 72 - {{- if .IsEmpty -}} 73 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 74 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"> 75 - <span aria-hidden="true" class="invisible"> 76 - {{ .LineNumber }} 77 - </span> 78 - </div> 79 - <div class="{{ $opStyle }}"> 80 - <span aria-hidden="true" class="invisible">{{ .Op.String }}</span> 81 - </div> 82 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 83 - </div> 84 - {{- else if eq .Op.String "+" -}} 85 - <div 86 - class="{{ $addStyle }} {{ $containerStyle }}" 87 - id="{{ $name }}-N{{ .LineNumber }}"> 88 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"> 89 - <a 90 - class="{{ $linkStyle }}" 91 - href="#{{ $name }}-N{{ .LineNumber }}"> 92 - {{ .LineNumber }} 93 - </a> 94 - </div> 95 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 96 - <div class="px-2">{{ .Content }}</div> 97 - </div> 98 - {{- else if eq .Op.String " " -}} 99 - <div 100 - class="{{ $ctxStyle }} {{ $containerStyle }}" 101 - id="{{ $name }}-N{{ .LineNumber }}"> 102 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"> 103 - <a 104 - class="{{ $linkStyle }}" 105 - href="#{{ $name }}-N{{ .LineNumber }}"> 106 - {{ .LineNumber }} 107 - </a> 108 - </div> 109 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 110 - <div class="px-2">{{ .Content }}</div> 111 - </div> 112 - {{- end -}} 113 - {{- end -}} 114 - {{- end -}}</div></div></pre> 115 - </div> 37 + <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 38 + {{- range .RightLines -}} 39 + {{- if .IsEmpty -}} 40 + <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 42 + <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 43 + <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 44 + </div> 45 + {{- else if eq .Op.String "+" -}} 46 + <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 48 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 49 + <div class="px-2" >{{ .Content }}</div> 50 + </div> 51 + {{- else if eq .Op.String " " -}} 52 + <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 54 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 55 + <div class="px-2">{{ .Content }}</div> 56 + </div> 57 + {{- end -}} 58 + {{- end -}} 59 + {{- end -}}</div></div></pre> 60 + </div> 116 61 {{ end }}
+45 -79
appview/pages/templates/repo/fragments/unifiedDiff.html
··· 1 1 {{ define "repo/fragments/unifiedDiff" }} 2 - {{ $name := .Id }} 3 - <pre 4 - class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}} 5 - <div 6 - class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center"> 7 - &middot;&middot;&middot; 8 - </div> 9 - {{- $oldStart := .OldPosition -}} 10 - {{- $newStart := .NewPosition -}} 11 - {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 12 - {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 13 - {{- $lineNrSepStyle1 := "" -}} 14 - {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 15 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 16 - {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 17 - {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 18 - {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 19 - {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 20 - {{- range .Lines -}} 21 - {{- if eq .Op.String "+" -}} 22 - <div 23 - class="{{ $addStyle }} {{ $containerStyle }}" 24 - id="{{ $name }}-N{{ $newStart }}"> 25 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle1 }}"> 26 - <span aria-hidden="true" class="invisible">{{ $newStart }}</span> 27 - </div> 28 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle2 }}"> 29 - <a class="{{ $linkStyle }}" href="#{{ $name }}-N{{ $newStart }}"> 30 - {{ $newStart }} 31 - </a> 32 - </div> 33 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 34 - <div class="px-2">{{ .Line }}</div> 35 - </div> 36 - {{- $newStart = add64 $newStart 1 -}} 37 - {{- end -}} 38 - {{- if eq .Op.String "-" -}} 39 - <div 40 - class="{{ $delStyle }} {{ $containerStyle }}" 41 - id="{{ $name }}-O{{ $oldStart }}"> 42 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle1 }}"> 43 - <a class="{{ $linkStyle }}" href="#{{ $name }}-O{{ $oldStart }}"> 44 - {{ $oldStart }} 45 - </a> 46 - </div> 47 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle2 }}"> 48 - <span aria-hidden="true" class="invisible">{{ $oldStart }}</span> 49 - </div> 50 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 51 - <div class="px-2">{{ .Line }}</div> 52 - </div> 53 - {{- $oldStart = add64 $oldStart 1 -}} 54 - {{- end -}} 55 - {{- if eq .Op.String " " -}} 56 - <div 57 - class="{{ $ctxStyle }} {{ $containerStyle }}" 58 - id="{{ $name }}-O{{ $oldStart }}-N{{ $newStart }}"> 59 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle1 }}"> 60 - <a 61 - class="{{ $linkStyle }}" 62 - href="#{{ $name }}-O{{ $oldStart }}-N{{ $newStart }}"> 63 - {{ $oldStart }} 64 - </a> 65 - </div> 66 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle2 }}"> 67 - <a 68 - class="{{ $linkStyle }}" 69 - href="#{{ $name }}-O{{ $oldStart }}-N{{ $newStart }}"> 70 - {{ $newStart }} 71 - </a> 72 - </div> 73 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 74 - <div class="px-2">{{ .Line }}</div> 75 - </div> 76 - {{- $newStart = add64 $newStart 1 -}} 77 - {{- $oldStart = add64 $oldStart 1 -}} 78 - {{- end -}} 79 - {{- end -}} 80 - {{- end -}}</div></div></pre> 2 + {{ $name := .Id }} 3 + <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 4 + {{- $oldStart := .OldPosition -}} 5 + {{- $newStart := .NewPosition -}} 6 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 7 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 + {{- $lineNrSepStyle1 := "" -}} 9 + {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 + {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 11 + {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 + {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 + {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 14 + {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 15 + {{- range .Lines -}} 16 + {{- if eq .Op.String "+" -}} 17 + <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 19 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 20 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 21 + <div class="px-2">{{ .Line }}</div> 22 + </div> 23 + {{- $newStart = add64 $newStart 1 -}} 24 + {{- end -}} 25 + {{- if eq .Op.String "-" -}} 26 + <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 28 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 29 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 30 + <div class="px-2">{{ .Line }}</div> 31 + </div> 32 + {{- $oldStart = add64 $oldStart 1 -}} 33 + {{- end -}} 34 + {{- if eq .Op.String " " -}} 35 + <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div> 37 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div> 38 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 39 + <div class="px-2">{{ .Line }}</div> 40 + </div> 41 + {{- $newStart = add64 $newStart 1 -}} 42 + {{- $oldStart = add64 $oldStart 1 -}} 43 + {{- end -}} 44 + {{- end -}} 45 + {{- end -}}</div></div></pre> 81 46 {{ end }} 47 +
+12 -21
appview/pages/templates/repo/pipelines/fragments/logBlock.html
··· 1 1 {{ define "repo/pipelines/fragments/logBlock" }} 2 - <div id="lines" hx-swap-oob="beforeend"> 3 - <details 4 - id="step-{{ .Id }}" 5 - {{ if not .Collapsed }}open{{ end }} 6 - class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700"> 7 - <summary 8 - class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400"> 9 - <div class="group-open:hidden flex items-center gap-1"> 10 - {{ i "chevron-right" "w-4 h-4" }} 11 - {{ .Name }} 12 - </div> 13 - <div class="hidden group-open:flex items-center gap-1"> 14 - {{ i "chevron-down" "w-4 h-4" }} 15 - {{ .Name }} 16 - </div> 17 - </summary> 18 - <div class="font-mono whitespace-pre overflow-x-auto px-2"> 19 - <div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div> 20 - <div id="step-body-{{ .Id }}"></div> 2 + <div id="lines" hx-swap-oob="beforeend"> 3 + <details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700"> 4 + <summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400"> 5 + <div class="group-open:hidden flex items-center gap-1"> 6 + {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 21 7 </div> 22 - </details> 23 - </div> 8 + <div class="hidden group-open:flex items-center gap-1"> 9 + {{ i "chevron-down" "w-4 h-4" }} {{ .Name }} 10 + </div> 11 + </summary> 12 + <div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 13 + </details> 14 + </div> 24 15 {{ end }}
+1
appview/pages/templates/repo/pulls/fragments/summarizedPullState.html
··· 17 17 18 18 {{ i $icon $style }} 19 19 {{ end }} 20 +
+146 -136
appview/pages/templates/repo/pulls/new.html
··· 1 1 {{ define "title" }}new pull &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "repoContent" }} 4 - <h2 class="font-bold text-sm mb-4 uppercase dark:text-white"> 5 - Create new pull request 6 - </h2> 7 - 8 - <form 9 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new" 10 - hx-indicator="#create-pull-spinner" 11 - hx-swap="none"> 12 - <div class="flex flex-col gap-6"> 13 - <div class="flex gap-2 items-center"> 14 - <p>First, choose a target branch on {{ .RepoInfo.FullName }}:</p> 15 - <div> 16 - <select 17 - required 18 - name="targetBranch" 19 - class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"> 20 - <option disabled selected>target branch</option> 21 - 22 - {{ range .Branches }} 23 - {{ $preset := false }} 24 - {{ if $.TargetBranch }} 25 - {{ $preset = eq .Reference.Name $.TargetBranch }} 26 - {{ else }} 27 - {{ $preset = .IsDefault }} 28 - {{ end }} 29 - 30 - 31 - <option 32 - value="{{ .Reference.Name }}" 33 - class="py-1" 34 - {{ if $preset }}selected{{ end }}> 35 - {{ .Reference.Name }} 36 - </option> 37 - {{ end }} 38 - </select> 39 - </div> 40 - </div> 41 - 42 - <div class="flex flex-col gap-2"> 43 - <h2 class="font-bold text-sm mb-4 uppercase dark:text-white"> 44 - Choose pull strategy 45 - </h2> 46 - <nav class="flex space-x-4 items-center"> 47 - <button 48 - type="button" 49 - class="btn" 50 - hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload" 51 - hx-target="#patch-strategy" 52 - hx-swap="innerHTML"> 53 - paste patch 54 - </button> 55 - 56 - {{ if .RepoInfo.Roles.IsPushAllowed }} 57 - <span class="text-sm text-gray-500 dark:text-gray-400">or</span> 58 - <button 59 - type="button" 60 - class="btn" 61 - hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches" 62 - hx-target="#patch-strategy" 63 - hx-swap="innerHTML"> 64 - compare branches 65 - </button> 66 - {{ end }} 67 - 68 - 69 - <span class="text-sm text-gray-500 dark:text-gray-400">or</span> 70 - <script> 71 - function getQueryParams() { 72 - return Object.fromEntries( 73 - new URLSearchParams(window.location.search), 74 - ); 75 - } 76 - </script> 77 - <!-- 4 + <h2 class="font-bold text-sm mb-4 uppercase dark:text-white"> 5 + Create new pull request 6 + </h2> 7 + 8 + <form 9 + hx-post="/{{ .RepoInfo.FullName }}/pulls/new" 10 + hx-indicator="#create-pull-spinner" 11 + hx-swap="none" 12 + > 13 + <div class="flex flex-col gap-6"> 14 + <div class="flex gap-2 items-center"> 15 + <p>First, choose a target branch on {{ .RepoInfo.FullName }}:</p> 16 + <div> 17 + <select 18 + required 19 + name="targetBranch" 20 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 21 + > 22 + <option disabled selected>target branch</option> 23 + 24 + 25 + {{ range .Branches }} 26 + 27 + {{ $preset := false }} 28 + {{ if $.TargetBranch }} 29 + {{ $preset = eq .Reference.Name $.TargetBranch }} 30 + {{ else }} 31 + {{ $preset = .IsDefault }} 32 + {{ end }} 33 + 34 + <option value="{{ .Reference.Name }}" class="py-1" {{if $preset}}selected{{end}}> 35 + {{ .Reference.Name }} 36 + </option> 37 + {{ end }} 38 + </select> 39 + </div> 40 + </div> 41 + 42 + <div class="flex flex-col gap-2"> 43 + <h2 class="font-bold text-sm mb-4 uppercase dark:text-white"> 44 + Choose pull strategy 45 + </h2> 46 + <nav class="flex space-x-4 items-center"> 47 + <button 48 + type="button" 49 + class="btn" 50 + hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload" 51 + hx-target="#patch-strategy" 52 + hx-swap="innerHTML" 53 + > 54 + paste patch 55 + </button> 56 + 57 + {{ if .RepoInfo.Roles.IsPushAllowed }} 58 + <span class="text-sm text-gray-500 dark:text-gray-400"> 59 + or 60 + </span> 61 + <button 62 + type="button" 63 + class="btn" 64 + hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches" 65 + hx-target="#patch-strategy" 66 + hx-swap="innerHTML" 67 + > 68 + compare branches 69 + </button> 70 + {{ end }} 71 + 72 + 73 + <span class="text-sm text-gray-500 dark:text-gray-400"> 74 + or 75 + </span> 76 + <script> 77 + function getQueryParams() { 78 + return Object.fromEntries(new URLSearchParams(window.location.search)); 79 + } 80 + </script> 81 + <!-- 78 82 since compare-forks need the server to load forks, we 79 83 hx-get this button; unlike simply loading the pullCompareForks template 80 84 as we do for the rest of the gang below. the hx-vals thing just populates 81 85 the query params so the forks page gets it. 82 86 --> 83 - <button 84 - type="button" 85 - class="btn" 86 - hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks" 87 - hx-target="#patch-strategy" 88 - hx-swap="innerHTML" 89 - {{ if eq .Strategy "fork" }} 90 - hx-trigger="click, load" hx-vals='js:{...getQueryParams()}' 91 - {{ end }}> 92 - compare forks 93 - </button> 94 - </nav> 95 - <section id="patch-strategy" class="flex flex-col gap-2"> 96 - {{ if eq .Strategy "patch" }} 97 - {{ template "repo/pulls/fragments/pullPatchUpload" . }} 98 - {{ else if eq .Strategy "branch" }} 99 - {{ template "repo/pulls/fragments/pullCompareBranches" . }} 100 - {{ else }} 101 - {{ template "repo/pulls/fragments/pullPatchUpload" . }} 102 - {{ end }} 103 - </section> 104 - 105 - <div id="patch-error" class="error dark:text-red-300"></div> 106 - </div> 107 - 108 - <div> 109 - <label for="title" class="dark:text-white">write a title</label> 110 - 111 - <input 112 - type="text" 113 - name="title" 114 - id="title" 115 - value="{{ .Title }}" 116 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" 117 - placeholder="One-line summary of your change." /> 118 - </div> 119 - 120 - <div> 121 - <label for="body" class="dark:text-white">add a description</label> 122 - 123 - <textarea 124 - name="body" 125 - id="body" 126 - rows="6" 127 - class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600" 128 - placeholder="Describe your change. Markdown is supported."> 129 - {{ .Body }}</textarea 130 - > 131 - </div> 132 - 133 - <div class="flex justify-start items-center gap-2 mt-4"> 134 - <button type="submit" class="btn-create flex items-center gap-2"> 135 - {{ i "git-pull-request-create" "w-4 h-4" }} 136 - create pull 137 - <span id="create-pull-spinner" class="group"> 138 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 139 - </span> 140 - </button> 141 - </div> 142 - </div> 143 - <div id="pull" class="error dark:text-red-300"></div> 144 - </form> 87 + <button 88 + type="button" 89 + class="btn" 90 + hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks" 91 + hx-target="#patch-strategy" 92 + hx-swap="innerHTML" 93 + {{ if eq .Strategy "fork" }} 94 + hx-trigger="click, load" 95 + hx-vals='js:{...getQueryParams()}' 96 + {{ end }} 97 + > 98 + compare forks 99 + </button> 100 + 101 + 102 + </nav> 103 + <section id="patch-strategy" class="flex flex-col gap-2"> 104 + {{ if eq .Strategy "patch" }} 105 + {{ template "repo/pulls/fragments/pullPatchUpload" . }} 106 + {{ else if eq .Strategy "branch" }} 107 + {{ template "repo/pulls/fragments/pullCompareBranches" . }} 108 + {{ else }} 109 + {{ template "repo/pulls/fragments/pullPatchUpload" . }} 110 + {{ end }} 111 + </section> 112 + 113 + <div id="patch-error" class="error dark:text-red-300"></div> 114 + </div> 115 + 116 + <div> 117 + <label for="title" class="dark:text-white">write a title</label> 118 + 119 + <input 120 + type="text" 121 + name="title" 122 + id="title" 123 + value="{{ .Title }}" 124 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" 125 + placeholder="One-line summary of your change." 126 + /> 127 + </div> 128 + 129 + <div> 130 + <label for="body" class="dark:text-white" 131 + >add a description</label 132 + > 133 + 134 + <textarea 135 + name="body" 136 + id="body" 137 + rows="6" 138 + class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600" 139 + placeholder="Describe your change. Markdown is supported." 140 + >{{ .Body }}</textarea> 141 + </div> 142 + 143 + <div class="flex justify-start items-center gap-2 mt-4"> 144 + <button type="submit" class="btn-create flex items-center gap-2"> 145 + {{ i "git-pull-request-create" "w-4 h-4" }} 146 + create pull 147 + <span id="create-pull-spinner" class="group"> 148 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 149 + </span> 150 + </button> 151 + </div> 152 + </div> 153 + <div id="pull" class="error dark:text-red-300"></div> 154 + </form> 145 155 {{ end }}
+8 -15
appview/pages/templates/repo/settings/fragments/secretListing.html
··· 1 1 {{ define "repo/settings/fragments/secretListing" }} 2 2 {{ $root := index . 0 }} 3 3 {{ $secret := index . 1 }} 4 - <div 5 - id="secret-{{ $secret.Key }}" 6 - class="flex items-center justify-between p-2"> 7 - <div 8 - class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]"> 4 + <div id="secret-{{$secret.Key}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]"> 9 6 <span class="font-mono"> 10 7 {{ $secret.Key }} 11 8 </span> 12 - <div 13 - class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 9 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 14 10 <span>added by</span> 15 - <span> 16 - {{ template "user/fragments/picHandleLink" $secret.CreatedBy }} 17 - </span> 11 + <span>{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}</span> 18 12 <span class="before:content-['·'] before:select-none"></span> 19 - <span> 20 - {{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }} 21 - </span> 13 + <span>{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}</span> 22 14 </div> 23 15 </div> 24 16 <button ··· 27 19 hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets" 28 20 hx-swap="none" 29 21 hx-vals='{"key": "{{ $secret.Key }}"}' 30 - hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?"> 22 + hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?" 23 + > 31 24 {{ i "trash-2" "w-5 h-5" }} 32 - <span class="hidden md:inline">delete</span> 25 + <span class="hidden md:inline">delete</span> 33 26 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 34 27 </button> 35 28 </div>
+7 -15
appview/pages/templates/repo/settings/fragments/sidebar.html
··· 1 1 {{ define "repo/settings/fragments/sidebar" }} 2 2 {{ $active := .Tab }} 3 3 {{ $tabs := .Tabs }} 4 - <div 5 - class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 4 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 6 5 {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 7 6 {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 8 7 {{ range $tabs }} 9 - <a 10 - href="/{{ $.RepoInfo.FullName }}/settings?tab={{ .Name }}" 11 - class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 12 - <div 13 - class="flex gap-3 items-center p-2 {{ if eq .Name $active }} 14 - {{ $activeTab }} 15 - {{ else }} 16 - {{ $inactiveTab }} 17 - {{ end }}"> 18 - {{ i .Icon "size-4" }} 19 - {{ .Name }} 20 - </div> 21 - </a> 8 + <a href="/{{ $.RepoInfo.FullName }}/settings?tab={{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 22 14 {{ end }} 23 15 </div> 24 16 {{ end }}
+84 -119
appview/pages/templates/timeline.html
··· 1 1 {{ define "title" }}timeline{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 - <meta property="og:title" content="timeline · tangled" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh" /> 7 - <meta property="og:description" content="see what's tangling" /> 4 + <meta property="og:title" content="timeline · tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh" /> 7 + <meta property="og:description" content="see what's tangling" /> 8 8 {{ end }} 9 9 10 10 {{ define "topbar" }} ··· 12 12 {{ end }} 13 13 14 14 {{ define "content" }} 15 - {{ with .LoggedInUser }} 16 - {{ block "timeline" $ }}{{ end }} 17 - {{ else }} 18 - {{ block "hero" $ }}{{ end }} 19 - {{ block "timeline" $ }}{{ end }} 20 - {{ end }} 15 + {{ with .LoggedInUser }} 16 + {{ block "timeline" $ }}{{ end }} 17 + {{ else }} 18 + {{ block "hero" $ }}{{ end }} 19 + {{ block "timeline" $ }}{{ end }} 20 + {{ end }} 21 21 {{ end }} 22 22 23 23 {{ define "hero" }} 24 - <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 25 - <div class="font-bold text-4xl"> 26 - tightly-knit 27 - <br /> 28 - social coding. 29 - </div> 24 + <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 25 + <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 30 26 31 - <p class="text-lg"> 32 - tangled is new social-enabled git collaboration platform built on 33 - <a class="underline" href="https://atproto.com/">atproto</a> 34 - . 35 - </p> 36 - <p class="text-lg"> 37 - we envision a place where developers have complete ownership of their 38 - code, open source communities can freely self-govern and most importantly, 39 - coding can be social and fun again. 40 - </p> 27 + <p class="text-lg"> 28 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 29 + </p> 30 + <p class="text-lg"> 31 + we envision a place where developers have complete ownership of their 32 + code, open source communities can freely self-govern and most 33 + importantly, coding can be social and fun again. 34 + </p> 41 35 42 - <div class="flex gap-6 items-center"> 43 - <a href="/signup" class="no-underline hover:no-underline "> 44 - <button class="btn-create flex gap-2 px-4 items-center"> 45 - join now 46 - {{ i "arrow-right" "size-4" }} 47 - </button> 48 - </a> 36 + <div class="flex gap-6 items-center"> 37 + <a href="/signup" class="no-underline hover:no-underline "> 38 + <button class="btn-create flex gap-2 px-4 items-center"> 39 + join now {{ i "arrow-right" "size-4" }} 40 + </button> 41 + </a> 42 + </div> 49 43 </div> 50 - </div> 51 44 {{ end }} 52 45 53 46 {{ define "timeline" }} 54 - <div> 55 - <div class="p-6"> 56 - <p class="text-xl font-bold dark:text-white">Timeline</p> 57 - </div> 47 + <div> 48 + <div class="p-6"> 49 + <p class="text-xl font-bold dark:text-white">Timeline</p> 50 + </div> 58 51 59 - <div class="flex flex-col gap-4"> 60 - {{ range $i, $e := .Timeline }} 61 - <div class="relative"> 62 - {{ if ne $i 0 }} 63 - <div 64 - class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 65 - {{ end }} 66 - {{ with $e }} 67 - <div 68 - class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 69 - {{ if .Repo }} 70 - {{ block "repoEvent" (list $ .Repo .Source) }}{{ end }} 71 - {{ else if .Star }} 72 - {{ block "starEvent" (list $ .Star) }}{{ end }} 73 - {{ else if .Follow }} 74 - {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} 75 - {{ end }} 52 + <div class="flex flex-col gap-4"> 53 + {{ range $i, $e := .Timeline }} 54 + <div class="relative"> 55 + {{ if ne $i 0 }} 56 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 57 + {{ end }} 58 + {{ with $e }} 59 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 60 + {{ if .Repo }} 61 + {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 62 + {{ else if .Star }} 63 + {{ block "starEvent" (list $ .Star) }} {{ end }} 64 + {{ else if .Follow }} 65 + {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 66 + {{ end }} 67 + </div> 76 68 {{ end }} 77 69 </div> 78 70 {{ end }} 79 71 </div> 80 - {{ end }} 81 72 </div> 82 - </div> 83 73 {{ end }} 84 74 85 75 {{ define "repoEvent" }} ··· 87 77 {{ $repo := index . 1 }} 88 78 {{ $source := index . 2 }} 89 79 {{ $userHandle := resolve $repo.Did }} 90 - <div 91 - class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 92 - {{ template "user/fragments/picHandleLink" $repo.Did }} 93 - {{ with $source }} 94 - {{ $sourceDid := resolve .Did }} 95 - forked 96 - <a 97 - href="/{{ $sourceDid }}/{{ .Name }}" 98 - class="no-underline hover:underline"> 99 - {{ $sourceDid }}/{{ .Name }} 100 - </a> 101 - to 102 - <a 103 - href="/{{ $userHandle }}/{{ $repo.Name }}" 104 - class="no-underline hover:underline"> 105 - {{ $repo.Name }} 106 - </a> 107 - {{ else }} 108 - created 109 - <a 110 - href="/{{ $userHandle }}/{{ $repo.Name }}" 111 - class="no-underline hover:underline"> 112 - {{ $repo.Name }} 113 - </a> 114 - {{ end }} 115 - <span class="text-gray-700 dark:text-gray-400 text-xs"> 116 - {{ template "repo/fragments/time" $repo.Created }} 117 - </span> 118 - </div> 80 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 81 + {{ template "user/fragments/picHandleLink" $repo.Did }} 82 + {{ with $source }} 83 + {{ $sourceDid := resolve .Did }} 84 + forked 85 + <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 86 + {{ $sourceDid }}/{{ .Name }} 87 + </a> 88 + to 89 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 90 + {{ else }} 91 + created 92 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 93 + {{ $repo.Name }} 94 + </a> 95 + {{ end }} 96 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 97 + </div> 119 98 {{ with $repo }} 120 99 {{ template "user/fragments/repoCard" (list $root . true) }} 121 100 {{ end }} ··· 127 106 {{ with $star }} 128 107 {{ $starrerHandle := resolve .StarredByDid }} 129 108 {{ $repoOwnerHandle := resolve .Repo.Did }} 130 - <div 131 - class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 132 - {{ template "user/fragments/picHandleLink" $starrerHandle }} 133 - starred 134 - <a 135 - href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" 136 - class="no-underline hover:underline"> 137 - {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 138 - </a> 139 - <span class="text-gray-700 dark:text-gray-400 text-xs"> 140 - {{ template "repo/fragments/time" .Created }} 141 - </span> 109 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 110 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 111 + starred 112 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 113 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 114 + </a> 115 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 142 116 </div> 143 117 {{ with .Repo }} 144 118 {{ template "user/fragments/repoCard" (list $root . true) }} ··· 146 120 {{ end }} 147 121 {{ end }} 148 122 123 + 149 124 {{ define "followEvent" }} 150 125 {{ $root := index . 0 }} 151 126 {{ $follow := index . 1 }} ··· 154 129 155 130 {{ $userHandle := resolve $follow.UserDid }} 156 131 {{ $subjectHandle := resolve $follow.SubjectDid }} 157 - <div 158 - class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 159 - {{ template "user/fragments/picHandleLink" $userHandle }} 160 - followed 161 - {{ template "user/fragments/picHandleLink" $subjectHandle }} 162 - <span class="text-gray-700 dark:text-gray-400 text-xs"> 163 - {{ template "repo/fragments/time" $follow.FollowedAt }} 164 - </span> 132 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 133 + {{ template "user/fragments/picHandleLink" $userHandle }} 134 + followed 135 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 136 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 165 137 </div> 166 - <div 167 - class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 138 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 168 139 <div class="flex-shrink-0 max-h-full w-24 h-24"> 169 - <img 170 - class="object-cover rounded-full p-2" 171 - src="{{ fullAvatar $subjectHandle }}" /> 140 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 172 141 </div> 173 142 174 143 <div class="flex-1 min-h-0 justify-around flex flex-col"> 175 144 <a href="/{{ $subjectHandle }}"> 176 - <span 177 - class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 178 - {{ $subjectHandle | truncateAt30 }} 179 - </span> 145 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 180 146 </a> 181 147 {{ with $profile }} 182 148 {{ with .Description }} 183 - <p class="text-sm pb-2 md:pb-2">{{ . }}</p> 149 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 184 150 {{ end }} 185 151 {{ end }} 186 152 {{ with $stat }} 187 - <div 188 - class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 153 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 189 154 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 190 155 <span id="followers">{{ .Followers }} followers</span> 191 156 <span class="select-none after:content-['·']"></span> 192 - <span id="following">{{ .Following }} following</span> 157 + <span id="following">{{ .Following }} following</span> 193 158 </div> 194 159 {{ end }} 195 160 </div>
+29 -48
appview/pages/templates/user/fragments/editPins.html
··· 1 1 {{ define "user/fragments/editPins" }} 2 2 {{ $profile := .Profile }} 3 - <form 4 - hx-post="/profile/pins" 5 - hx-disabled-elt="#save-btn,#cancel-btn" 6 - hx-swap="none" 7 - hx-indicator="#spinner"> 8 - <div class="flex items-center justify-between mb-2"> 9 - <p class="text-sm font-bold p-2 dark:text-white">SELECT PINNED REPOS</p> 10 - <div class="flex items-center gap-2"> 11 - <button 12 - id="save-btn" 13 - type="submit" 14 - class="btn px-2 flex items-center gap-2 no-underline text-sm"> 15 - {{ i "check" "w-3 h-3" }} save 16 - <span id="spinner" class="group"> 17 - {{ i "loader-circle" "w-3 h-3 animate-spin hidden group-[.htmx-request]:inline" }} 18 - </span> 19 - </button> 20 - <a 21 - href="/{{ .LoggedInUser.Did }}" 22 - class="w-full no-underline hover:no-underline"> 23 - <button 24 - id="cancel-btn" 25 - type="button" 26 - class="btn px-2 w-full flex items-center gap-2 no-underline text-sm"> 27 - {{ i "x" "w-3 h-3" }} cancel 3 + <form 4 + hx-post="/profile/pins" 5 + hx-disabled-elt="#save-btn,#cancel-btn" 6 + hx-swap="none" 7 + hx-indicator="#spinner"> 8 + <div class="flex items-center justify-between mb-2"> 9 + <p class="text-sm font-bold p-2 dark:text-white">SELECT PINNED REPOS</p> 10 + <div class="flex items-center gap-2"> 11 + <button id="save-btn" type="submit" class="btn px-2 flex items-center gap-2 no-underline text-sm"> 12 + {{ i "check" "w-3 h-3" }} save 13 + <span id="spinner" class="group"> 14 + {{ i "loader-circle" "w-3 h-3 animate-spin hidden group-[.htmx-request]:inline" }} 15 + </span> 28 16 </button> 29 - </a> 17 + <a href="/{{.LoggedInUser.Did}}" class="w-full no-underline hover:no-underline"> 18 + <button id="cancel-btn" type="button" class="btn px-2 w-full flex items-center gap-2 no-underline text-sm"> 19 + {{ i "x" "w-3 h-3" }} cancel 20 + </button> 21 + </a> 22 + </div> 30 23 </div> 31 - </div> 32 - <div 33 - id="repos" 34 - class="grid grid-cols-1 gap-1 mb-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"> 35 - {{ range $idx, $r := .AllRepos }} 36 - <div 37 - class="flex items-center gap-2 text-base p-2 border-b border-gray-200 dark:border-gray-700"> 38 - <input 39 - type="checkbox" 40 - id="repo-{{ $idx }}" 41 - name="pinnedRepo{{ $idx }}" 42 - value="{{ .RepoAt }}" 43 - {{ if .IsPinned }}checked{{ end }} /> 44 - <label 45 - for="repo-{{ $idx }}" 46 - class="my-0 py-0 normal-case font-normal w-full"> 24 + <div id="repos" class="grid grid-cols-1 gap-1 mb-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"> 25 + {{ range $idx, $r := .AllRepos }} 26 + <div class="flex items-center gap-2 text-base p-2 border-b border-gray-200 dark:border-gray-700"> 27 + <input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}> 28 + <label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full"> 47 29 <div class="flex justify-between items-center w-full"> 48 - <span class="flex-shrink-0 overflow-hidden text-ellipsis "> 49 - {{ resolve .Did }}/{{ .Name }} 50 - </span> 30 + <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ resolve .Did }}/{{.Name}}</span> 51 31 <div class="flex gap-1 items-center"> 52 32 {{ i "star" "size-4 fill-current" }} 53 33 <span>{{ .RepoStats.StarCount }}</span> ··· 55 35 </div> 56 36 </label> 57 37 </div> 58 - {{ end }} 59 - </div> 60 - </form> 38 + {{ end }} 39 + </div> 40 + 41 + </form> 61 42 {{ end }}
+66 -112
appview/pages/templates/user/profile.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 - <meta property="og:type" content="profile" /> 6 - <meta 7 - property="og:url" 8 - content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 9 - <meta 10 - property="og:description" 11 - content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 12 8 {{ end }} 13 9 14 10 {{ define "content" }} 15 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 11 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 16 12 <div class="md:col-span-3 order-1 md:order-1"> 17 13 <div class="grid grid-cols-1 gap-4"> 18 14 {{ template "user/fragments/profileCard" .Card }} 19 - {{ block "punchcard" .Punchcard }}{{ end }} 15 + {{ block "punchcard" .Punchcard }} {{ end }} 20 16 </div> 21 17 </div> 22 18 <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> ··· 26 22 </div> 27 23 </div> 28 24 <div class="md:col-span-4 order-3 md:order-3"> 29 - {{ block "profileTimeline" . }}{{ end }} 25 + {{ block "profileTimeline" . }}{{ end }} 30 26 </div> 31 - </div> 27 + </div> 32 28 {{ end }} 33 29 34 30 {{ define "profileTimeline" }} ··· 37 33 {{ with .ProfileTimeline }} 38 34 {{ range $idx, $byMonth := .ByMonth }} 39 35 {{ with $byMonth }} 40 - <div 41 - class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 42 - {{ if eq $idx 0 }} 36 + <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 37 + {{ if eq $idx 0 }} 43 38 44 - {{ else }} 45 - {{ $s := "s" }} 46 - {{ if eq $idx 1 }} 47 - {{ $s = "" }} 48 - {{ end }} 49 - <p class="text-sm font-bold dark:text-white mb-2"> 50 - {{ $idx }} month{{ $s }} ago 51 - </p> 39 + {{ else }} 40 + {{ $s := "s" }} 41 + {{ if eq $idx 1 }} 42 + {{ $s = "" }} 52 43 {{ end }} 44 + <p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p> 45 + {{ end }} 46 + 47 + {{ if .IsEmpty }} 48 + <div class="text-gray-500 dark:text-gray-400"> 49 + No activity for this month 50 + </div> 51 + {{ else }} 52 + <div class="flex flex-col gap-1"> 53 + {{ block "repoEvents" .RepoEvents }} {{ end }} 54 + {{ block "issueEvents" .IssueEvents }} {{ end }} 55 + {{ block "pullEvents" .PullEvents }} {{ end }} 56 + </div> 57 + {{ end }} 58 + </div> 53 59 54 - {{ if .IsEmpty }} 55 - <div class="text-gray-500 dark:text-gray-400"> 56 - No activity for this month 57 - </div> 58 - {{ else }} 59 - <div class="flex flex-col gap-1"> 60 - {{ block "repoEvents" .RepoEvents }}{{ end }} 61 - {{ block "issueEvents" .IssueEvents }}{{ end }} 62 - {{ block "pullEvents" .PullEvents }}{{ end }} 63 - </div> 64 - {{ end }} 65 - </div> 66 60 {{ end }} 67 61 {{ else }} 68 62 <p class="dark:text-white">This user does not have any activity yet.</p> ··· 74 68 {{ define "repoEvents" }} 75 69 {{ if gt (len .) 0 }} 76 70 <details> 77 - <summary 78 - class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 71 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 79 72 <div class="flex flex-wrap items-center gap-2"> 80 73 {{ i "book-plus" "w-4 h-4" }} 81 - created 82 - {{ len . }} 83 - {{ if eq (len .) 1 }}repository{{ else }}repositories{{ end }} 74 + created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}} 84 75 </div> 85 76 </summary> 86 77 <div class="py-2 text-sm flex flex-col gap-3 mb-2"> ··· 93 84 {{ i "book-plus" "w-4 h-4" }} 94 85 {{ end }} 95 86 </span> 96 - <a 97 - href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 98 - class="no-underline hover:underline"> 87 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 99 88 {{- .Repo.Name -}} 100 89 </a> 101 90 </div> ··· 111 100 112 101 {{ if gt (len $items) 0 }} 113 102 <details> 114 - <summary 115 - class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 103 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 116 104 <div class="flex flex-wrap items-center gap-2"> 117 105 {{ i "circle-dot" "w-4 h-4" }} 118 106 119 - 120 107 <div> 121 - created 122 - {{ len $items }} 123 - {{ if eq (len $items) 1 }}issue{{ else }}issues{{ end }} 108 + created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 124 109 </div> 125 110 126 111 {{ if gt $stats.Open 0 }} 127 - <span 128 - class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 129 - {{ $stats.Open }} open 112 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 113 + {{$stats.Open}} open 130 114 </span> 131 115 {{ end }} 132 116 133 117 {{ if gt $stats.Closed 0 }} 134 - <span 135 - class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 136 - {{ $stats.Closed }} closed 137 - </span> 118 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 119 + {{$stats.Closed}} closed 120 + </span> 138 121 {{ end }} 139 122 140 123 </div> ··· 145 128 {{ $repoName := .Metadata.Repo.Name }} 146 129 {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 147 130 148 - 149 131 <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 150 132 {{ if .Open }} 151 133 <span class="text-green-600 dark:text-green-500"> ··· 157 139 </span> 158 140 {{ end }} 159 141 <div class="flex-none min-w-8 text-right"> 160 - <span class="text-gray-500 dark:text-gray-400"> 161 - #{{ .IssueId }} 162 - </span> 142 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 163 143 </div> 164 144 <div class="break-words max-w-full"> 165 - <a 166 - href="/{{ $repoUrl }}/issues/{{ .IssueId }}" 167 - class="no-underline hover:underline"> 145 + <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 168 146 {{ .Title -}} 169 147 </a> 170 148 on 171 - <a 172 - href="/{{ $repoUrl }}" 173 - class="no-underline hover:underline whitespace-nowrap"> 174 - {{ $repoUrl }} 149 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 150 + {{$repoUrl}} 175 151 </a> 176 152 </div> 177 153 </div> ··· 186 162 {{ $stats := .Stats }} 187 163 {{ if gt (len $items) 0 }} 188 164 <details> 189 - <summary 190 - class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 165 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 191 166 <div class="flex flex-wrap items-center gap-2"> 192 167 {{ i "git-pull-request" "w-4 h-4" }} 193 168 194 - 195 169 <div> 196 - created 197 - {{ len $items }} 198 - {{ if eq (len $items) 1 }} 199 - pull request 200 - {{ else }} 201 - pull requests 202 - {{ end }} 170 + created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 203 171 </div> 204 172 205 173 {{ if gt $stats.Open 0 }} 206 - <span 207 - class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 208 - {{ $stats.Open }} open 174 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 175 + {{$stats.Open}} open 209 176 </span> 210 177 {{ end }} 211 178 212 179 {{ if gt $stats.Merged 0 }} 213 - <span 214 - class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 215 - {{ $stats.Merged }} merged 180 + <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 181 + {{$stats.Merged}} merged 216 182 </span> 217 183 {{ end }} 218 184 185 + 219 186 {{ if gt $stats.Closed 0 }} 220 - <span 221 - class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 222 - {{ $stats.Closed }} closed 223 - </span> 187 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 188 + {{$stats.Closed}} closed 189 + </span> 224 190 {{ end }} 225 191 226 192 </div> ··· 231 197 {{ $repoName := .Repo.Name }} 232 198 {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 233 199 234 - 235 200 <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 236 201 {{ if .State.IsOpen }} 237 202 <span class="text-green-600 dark:text-green-500"> ··· 247 212 </span> 248 213 {{ end }} 249 214 <div class="flex-none min-w-8 text-right"> 250 - <span class="text-gray-500 dark:text-gray-400"> 251 - #{{ .PullId }} 252 - </span> 215 + <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 253 216 </div> 254 217 <div class="break-words max-w-full"> 255 - <a 256 - href="/{{ $repoUrl }}/pulls/{{ .PullId }}" 257 - class="no-underline hover:underline"> 218 + <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 258 219 {{ .Title -}} 259 220 </a> 260 221 on 261 - <a 262 - href="/{{ $repoUrl }}" 263 - class="no-underline hover:underline whitespace-nowrap"> 264 - {{ $repoUrl }} 222 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 223 + {{$repoUrl}} 265 224 </a> 266 225 </div> 267 226 </div> ··· 273 232 274 233 {{ define "ownRepos" }} 275 234 <div> 276 - <div 277 - class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2"> 278 - <a 279 - href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 235 + <div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2"> 236 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 280 237 class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 281 238 <span>PINNED REPOS</span> 282 - <span 283 - class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 "> 284 - view all 285 - {{ i "chevron-right" "w-4 h-4" }} 239 + <span class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 "> 240 + view all {{ i "chevron-right" "w-4 h-4" }} 286 241 </span> 287 242 </a> 288 243 {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 289 - <button 244 + <button 290 245 hx-get="profile/edit-pins" 291 246 hx-target="#all-repos" 292 247 class="btn py-0 font-normal text-sm flex gap-2 items-center group"> ··· 300 255 {{ range .Repos }} 301 256 {{ template "user/fragments/repoCard" (list $ . false) }} 302 257 {{ else }} 303 - <p class="px-6 dark:text-white"> 304 - This user does not have any repos yet. 305 - </p> 258 + <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 306 259 {{ end }} 307 260 </div> 308 261 </div> ··· 316 269 {{ range .CollaboratingRepos }} 317 270 {{ template "user/fragments/repoCard" (list $ . true) }} 318 271 {{ else }} 319 - <p class="px-6 dark:text-white">This user is not collaborating.</p> 272 + <p class="px-6 dark:text-white">This user is not collaborating.</p> 320 273 {{ end }} 321 274 </div> 322 275 </div> ··· 355 308 <div class="w-full h-full flex justify-center items-center"> 356 309 <div 357 310 class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 358 - title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"></div> 311 + title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 312 + </div> 359 313 </div> 360 314 {{ end }} 361 315 </div>
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
··· 1 - {{ define "repo/fragments/cloneInstructions" }} 2 - {{ $knot := .RepoInfo.Knot }} 3 - {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 5 - {{ end }} 6 - <section 7 - class="mt-4 p-6 rounded drop-shadow-sm bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4" 8 - > 9 - <div class="flex flex-col gap-2"> 10 - <strong>push</strong> 11 - <div class="md:pl-4 overflow-x-auto whitespace-nowrap"> 12 - <code class="dark:text-gray-100" 13 - >git remote add origin 14 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 15 - > 16 - </div> 17 - </div> 18 - 19 - <div class="flex flex-col gap-2"> 20 - <strong>clone</strong> 21 - <div class="md:pl-4 flex flex-col gap-2"> 22 - <div class="flex items-center gap-3"> 23 - <span 24 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 25 - >HTTP</span 26 - > 27 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 28 - <code class="dark:text-gray-100" 29 - >git clone 30 - https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code 31 - > 32 - </div> 33 - </div> 34 - 35 - <div class="flex items-center gap-3"> 36 - <span 37 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 38 - >SSH</span 39 - > 40 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 41 - <code class="dark:text-gray-100" 42 - >git clone 43 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 44 - > 45 - </div> 46 - </div> 47 - </div> 48 - </div> 49 - 50 - <p class="py-2 text-gray-500 dark:text-gray-400"> 51 - Note that for self-hosted knots, clone URLs may be different based 52 - on your setup. 53 - </p> 54 - </section> 55 - {{ end }}
+1 -1
spindle/engine/ansi_stripper.go spindle/engines/nixery/ansi_stripper.go
··· 1 - package engine 1 + package nixery 2 2 3 3 import ( 4 4 "io"
+1 -1
spindle/engine/envs.go spindle/engines/nixery/envs.go
··· 1 - package engine 1 + package nixery 2 2 3 3 import ( 4 4 "fmt"
+1 -1
spindle/engine/envs_test.go spindle/engines/nixery/envs_test.go
··· 1 - package engine 1 + package nixery 2 2 3 3 import ( 4 4 "testing"
+7
spindle/engines/nixery/errors.go
··· 1 + package nixery 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrOOMKilled = errors.New("oom killed") 7 + )
+8 -10
spindle/engine/logger.go spindle/models/logger.go
··· 1 - package engine 1 + package models 2 2 3 3 import ( 4 4 "encoding/json" ··· 7 7 "os" 8 8 "path/filepath" 9 9 "strings" 10 - 11 - "tangled.sh/tangled.sh/core/spindle/models" 12 10 ) 13 11 14 12 type WorkflowLogger struct { ··· 16 14 encoder *json.Encoder 17 15 } 18 16 19 - func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) { 17 + func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 20 18 path := LogFilePath(baseDir, wid) 21 19 22 20 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 30 28 }, nil 31 29 } 32 30 33 - func LogFilePath(baseDir string, workflowID models.WorkflowId) string { 31 + func LogFilePath(baseDir string, workflowID WorkflowId) string { 34 32 logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 35 33 return logFilePath 36 34 } ··· 47 45 } 48 46 } 49 47 50 - func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer { 48 + func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 51 49 return &controlWriter{ 52 50 logger: l, 53 51 idx: idx, ··· 62 60 63 61 func (w *dataWriter) Write(p []byte) (int, error) { 64 62 line := strings.TrimRight(string(p), "\r\n") 65 - entry := models.NewDataLogLine(line, w.stream) 63 + entry := NewDataLogLine(line, w.stream) 66 64 if err := w.logger.encoder.Encode(entry); err != nil { 67 65 return 0, err 68 66 } ··· 72 70 type controlWriter struct { 73 71 logger *WorkflowLogger 74 72 idx int 75 - step models.Step 73 + step Step 76 74 } 77 75 78 76 func (w *controlWriter) Write(_ []byte) (int, error) { 79 - entry := models.NewControlLogLine(w.idx, w.step) 77 + entry := NewControlLogLine(w.idx, w.step) 80 78 if err := w.logger.encoder.Encode(entry); err != nil { 81 79 return 0, err 82 80 } 83 - return len(w.step.Name), nil 81 + return len(w.step.Name()), nil 84 82 }
+8 -103
spindle/models/pipeline.go
··· 1 1 package models 2 2 3 - import ( 4 - "path" 5 - 6 - "tangled.sh/tangled.sh/core/api/tangled" 7 - "tangled.sh/tangled.sh/core/spindle/config" 8 - ) 9 - 10 3 type Pipeline struct { 11 4 RepoOwner string 12 5 RepoName string 13 - Workflows []Workflow 6 + Workflows map[Engine][]Workflow 14 7 } 15 8 16 - type Step struct { 17 - Command string 18 - Name string 19 - Environment map[string]string 20 - Kind StepKind 9 + type Step interface { 10 + Name() string 11 + Command() string 12 + Kind() StepKind 21 13 } 22 14 23 15 type StepKind int ··· 30 22 ) 31 23 32 24 type Workflow struct { 33 - Steps []Step 34 - Environment map[string]string 35 - Name string 36 - Image string 37 - } 38 - 39 - // setupSteps get added to start of Steps 40 - type setupSteps []Step 41 - 42 - // addStep adds a step to the beginning of the workflow's steps. 43 - func (ss *setupSteps) addStep(step Step) { 44 - *ss = append(*ss, step) 45 - } 46 - 47 - // ToPipeline converts a tangled.Pipeline into a model.Pipeline. 48 - // In the process, dependencies are resolved: nixpkgs deps 49 - // are constructed atop nixery and set as the Workflow.Image, 50 - // and ones from custom registries 51 - func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline { 52 - workflows := []Workflow{} 53 - 54 - for _, twf := range pl.Workflows { 55 - swf := &Workflow{} 56 - for _, tstep := range twf.Steps { 57 - sstep := Step{} 58 - sstep.Environment = stepEnvToMap(tstep.Environment) 59 - sstep.Command = tstep.Command 60 - sstep.Name = tstep.Name 61 - sstep.Kind = StepKindUser 62 - swf.Steps = append(swf.Steps, sstep) 63 - } 64 - swf.Name = twf.Name 65 - swf.Environment = workflowEnvToMap(twf.Environment) 66 - swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 67 - 68 - setup := &setupSteps{} 69 - 70 - setup.addStep(nixConfStep()) 71 - setup.addStep(cloneStep(*twf, *pl.TriggerMetadata, cfg.Server.Dev)) 72 - // this step could be empty 73 - if s := dependencyStep(*twf); s != nil { 74 - setup.addStep(*s) 75 - } 76 - 77 - // append setup steps in order to the start of workflow steps 78 - swf.Steps = append(*setup, swf.Steps...) 79 - 80 - workflows = append(workflows, *swf) 81 - } 82 - repoOwner := pl.TriggerMetadata.Repo.Did 83 - repoName := pl.TriggerMetadata.Repo.Repo 84 - return &Pipeline{ 85 - RepoOwner: repoOwner, 86 - RepoName: repoName, 87 - Workflows: workflows, 88 - } 89 - } 90 - 91 - func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 92 - envMap := map[string]string{} 93 - for _, env := range envs { 94 - if env != nil { 95 - envMap[env.Key] = env.Value 96 - } 97 - } 98 - return envMap 99 - } 100 - 101 - func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 102 - envMap := map[string]string{} 103 - for _, env := range envs { 104 - if env != nil { 105 - envMap[env.Key] = env.Value 106 - } 107 - } 108 - return envMap 109 - } 110 - 111 - func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string { 112 - var dependencies string 113 - for _, d := range deps { 114 - if d.Registry == "nixpkgs" { 115 - dependencies = path.Join(d.Packages...) 116 - } 117 - } 118 - 119 - // load defaults from somewhere else 120 - dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 121 - 122 - return path.Join(nixery, dependencies) 25 + Steps []Step 26 + Name string 27 + Data any 123 28 }
+1 -86
workflow/def_test.go
··· 10 10 yamlData := ` 11 11 when: 12 12 - event: ["push", "pull_request"] 13 - branch: ["main", "develop"] 14 - 15 - dependencies: 16 - nixpkgs: 17 - - go 18 - - git 19 - - curl 20 - 21 - steps: 22 - - name: "Test" 23 - command: | 24 - go test ./...` 13 + branch: ["main", "develop"]` 25 14 26 15 wf, err := FromFile("test.yml", []byte(yamlData)) 27 16 assert.NoError(t, err, "YAML should unmarshal without error") ··· 30 19 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 31 20 assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) 32 21 33 - assert.Len(t, wf.Steps, 1) 34 - assert.Equal(t, "Test", wf.Steps[0].Name) 35 - assert.Equal(t, "go test ./...", wf.Steps[0].Command) 36 - 37 - pkgs, ok := wf.Dependencies["nixpkgs"] 38 - assert.True(t, ok, "`nixpkgs` should be present in dependencies") 39 - assert.ElementsMatch(t, []string{"go", "git", "curl"}, pkgs) 40 - 41 22 assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 42 23 } 43 24 44 - func TestUnmarshalCustomRegistry(t *testing.T) { 45 - yamlData := ` 46 - when: 47 - - event: push 48 - branch: main 49 - 50 - dependencies: 51 - git+https://tangled.sh/@oppi.li/tbsp: 52 - - tbsp 53 - git+https://git.peppe.rs/languages/statix: 54 - - statix 55 - 56 - steps: 57 - - name: "Check" 58 - command: | 59 - statix check` 60 - 61 - wf, err := FromFile("test.yml", []byte(yamlData)) 62 - assert.NoError(t, err, "YAML should unmarshal without error") 63 - 64 - assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event) 65 - assert.ElementsMatch(t, []string{"main"}, wf.When[0].Branch) 66 - 67 - assert.ElementsMatch(t, []string{"tbsp"}, wf.Dependencies["git+https://tangled.sh/@oppi.li/tbsp"]) 68 - assert.ElementsMatch(t, []string{"statix"}, wf.Dependencies["git+https://git.peppe.rs/languages/statix"]) 69 - } 70 - 71 25 func TestUnmarshalCloneFalse(t *testing.T) { 72 26 yamlData := ` 73 27 when: ··· 75 29 76 30 clone: 77 31 skip: true 78 - 79 - dependencies: 80 - nixpkgs: 81 - - python3 82 - 83 - steps: 84 - - name: Notify 85 - command: | 86 - python3 ./notify.py 87 32 ` 88 33 89 34 wf, err := FromFile("test.yml", []byte(yamlData)) ··· 93 38 94 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 95 40 } 96 - 97 - func TestUnmarshalEnv(t *testing.T) { 98 - yamlData := ` 99 - when: 100 - - event: ["pull_request_close"] 101 - 102 - clone: 103 - skip: false 104 - 105 - environment: 106 - HOME: /home/foo bar/baz 107 - CGO_ENABLED: 1 108 - 109 - steps: 110 - - name: Something 111 - command: echo "hello" 112 - environment: 113 - FOO: bar 114 - BAZ: qux 115 - ` 116 - 117 - wf, err := FromFile("test.yml", []byte(yamlData)) 118 - assert.NoError(t, err) 119 - 120 - assert.Len(t, wf.Environment, 2) 121 - assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"]) 122 - assert.Equal(t, "1", wf.Environment["CGO_ENABLED"]) 123 - assert.Equal(t, "bar", wf.Steps[0].Environment["FOO"]) 124 - assert.Equal(t, "qux", wf.Steps[0].Environment["BAZ"]) 125 - }
+1 -1
appview/pages/templates/repo/fragments/repoDescription.html
··· 1 1 {{ define "repo/fragments/repoDescription" }} 2 2 <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 3 {{ if .RepoInfo.Description }} 4 - {{ .RepoInfo.Description }} 4 + {{ .RepoInfo.Description | description }} 5 5 {{ else }} 6 6 <span class="italic">this repo has no description</span> 7 7 {{ end }}
+1 -1
appview/pages/templates/strings/fragments/form.html
··· 13 13 type="text" 14 14 id="filename" 15 15 name="filename" 16 - placeholder="Filename with extension" 16 + placeholder="Filename" 17 17 required 18 18 value="{{ .String.Filename }}" 19 19 class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
+62
appview/pages/templates/user/settings/fragments/emailListing.html
··· 1 + {{ define "user/settings/fragments/emailListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $email := index . 1 }} 4 + <div id="email-{{$email.Address}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 6 + <div class="flex items-center gap-2"> 7 + {{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }} 8 + <span class="font-bold"> 9 + {{ $email.Address }} 10 + </span> 11 + <div class="inline-flex items-center gap-1"> 12 + {{ if $email.Verified }} 13 + <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 14 + {{ else }} 15 + <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 16 + {{ end }} 17 + {{ if $email.Primary }} 18 + <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 19 + {{ end }} 20 + </div> 21 + </div> 22 + <div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 23 + <span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span> 24 + </div> 25 + </div> 26 + <div class="flex gap-2 items-center"> 27 + {{ if not $email.Verified }} 28 + <button 29 + class="btn flex gap-2 text-sm px-2 py-1" 30 + hx-post="/settings/emails/verify/resend" 31 + hx-swap="none" 32 + hx-vals='{"email": "{{ $email.Address }}"}'> 33 + {{ i "rotate-cw" "w-4 h-4" }} 34 + <span class="hidden md:inline">resend</span> 35 + </button> 36 + {{ end }} 37 + {{ if and (not $email.Primary) $email.Verified }} 38 + <button 39 + class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" 40 + hx-post="/settings/emails/primary" 41 + hx-swap="none" 42 + hx-vals='{"email": "{{ $email.Address }}"}'> 43 + set as primary 44 + </button> 45 + {{ end }} 46 + {{ if not $email.Primary }} 47 + <button 48 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 + title="Delete email" 50 + hx-delete="/settings/emails" 51 + hx-swap="none" 52 + hx-vals='{"email": "{{ $email.Address }}"}' 53 + hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?" 54 + > 55 + {{ i "trash-2" "w-5 h-5" }} 56 + <span class="hidden md:inline">delete</span> 57 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + </button> 59 + {{ end }} 60 + </div> 61 + </div> 62 + {{ end }}
+31
appview/pages/templates/user/settings/fragments/keyListing.html
··· 1 + {{ define "user/settings/fragments/keyListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $key := index . 1 }} 4 + <div id="key-{{$key.Name}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]"> 6 + <div class="flex items-center gap-2"> 7 + <span>{{ i "key" "w-4" "h-4" }}</span> 8 + <span class="font-bold"> 9 + {{ $key.Name }} 10 + </span> 11 + </div> 12 + <span class="font-mono text-sm text-gray-500 dark:text-gray-400"> 13 + {{ sshFingerprint $key.Key }} 14 + </span> 15 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 16 + <span>added {{ template "repo/fragments/time" $key.Created }}</span> 17 + </div> 18 + </div> 19 + <button 20 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 21 + title="Delete key" 22 + hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}" 23 + hx-swap="none" 24 + hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?" 25 + > 26 + {{ i "trash-2" "w-5 h-5" }} 27 + <span class="hidden md:inline">delete</span> 28 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 29 + </button> 30 + </div> 31 + {{ end }}
+16
appview/pages/templates/user/settings/fragments/sidebar.html
··· 1 + {{ define "user/settings/fragments/sidebar" }} 2 + {{ $active := .Tab }} 3 + {{ $tabs := .Tabs }} 4 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 + {{ range $tabs }} 8 + <a href="/settings/{{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+34
api/tangled/repocreate.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.create 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoCreateNSID = "sh.tangled.repo.create" 15 + ) 16 + 17 + // RepoCreate_Input is the input argument to a sh.tangled.repo.create call. 18 + type RepoCreate_Input struct { 19 + // defaultBranch: Default branch to push to 20 + DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"` 21 + // rkey: Rkey of the repository record 22 + Rkey string `json:"rkey" cborgen:"rkey"` 23 + // source: A source URL to clone from, populate this when forking or importing a repository. 24 + Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 25 + } 26 + 27 + // RepoCreate calls the XRPC method "sh.tangled.repo.create". 28 + func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+45
api/tangled/repoforkStatus.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.forkStatus 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoForkStatusNSID = "sh.tangled.repo.forkStatus" 15 + ) 16 + 17 + // RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call. 18 + type RepoForkStatus_Input struct { 19 + // branch: Branch to check status for 20 + Branch string `json:"branch" cborgen:"branch"` 21 + // did: DID of the fork owner 22 + Did string `json:"did" cborgen:"did"` 23 + // hiddenRef: Hidden ref to use for comparison 24 + HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"` 25 + // name: Name of the forked repository 26 + Name string `json:"name" cborgen:"name"` 27 + // source: Source repository URL 28 + Source string `json:"source" cborgen:"source"` 29 + } 30 + 31 + // RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call. 32 + type RepoForkStatus_Output struct { 33 + // status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch 34 + Status int64 `json:"status" cborgen:"status"` 35 + } 36 + 37 + // RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus". 38 + func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) { 39 + var out RepoForkStatus_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
+36
api/tangled/repoforkSync.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.forkSync 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoForkSyncNSID = "sh.tangled.repo.forkSync" 15 + ) 16 + 17 + // RepoForkSync_Input is the input argument to a sh.tangled.repo.forkSync call. 18 + type RepoForkSync_Input struct { 19 + // branch: Branch to sync 20 + Branch string `json:"branch" cborgen:"branch"` 21 + // did: DID of the fork owner 22 + Did string `json:"did" cborgen:"did"` 23 + // name: Name of the forked repository 24 + Name string `json:"name" cborgen:"name"` 25 + // source: AT-URI of the source repository 26 + Source string `json:"source" cborgen:"source"` 27 + } 28 + 29 + // RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync". 30 + func RepoForkSync(ctx context.Context, c util.LexClient, input *RepoForkSync_Input) error { 31 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkSync", nil, input, nil); err != nil { 32 + return err 33 + } 34 + 35 + return nil 36 + }
+45
api/tangled/repohiddenRef.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.hiddenRef 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoHiddenRefNSID = "sh.tangled.repo.hiddenRef" 15 + ) 16 + 17 + // RepoHiddenRef_Input is the input argument to a sh.tangled.repo.hiddenRef call. 18 + type RepoHiddenRef_Input struct { 19 + // forkRef: Fork reference name 20 + ForkRef string `json:"forkRef" cborgen:"forkRef"` 21 + // remoteRef: Remote reference name 22 + RemoteRef string `json:"remoteRef" cborgen:"remoteRef"` 23 + // repo: AT-URI of the repository 24 + Repo string `json:"repo" cborgen:"repo"` 25 + } 26 + 27 + // RepoHiddenRef_Output is the output of a sh.tangled.repo.hiddenRef call. 28 + type RepoHiddenRef_Output struct { 29 + // error: Error message if creation failed 30 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 31 + // ref: The created hidden ref name 32 + Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` 33 + // success: Whether the hidden ref was created successfully 34 + Success bool `json:"success" cborgen:"success"` 35 + } 36 + 37 + // RepoHiddenRef calls the XRPC method "sh.tangled.repo.hiddenRef". 38 + func RepoHiddenRef(ctx context.Context, c util.LexClient, input *RepoHiddenRef_Input) (*RepoHiddenRef_Output, error) { 39 + var out RepoHiddenRef_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.hiddenRef", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
+44
api/tangled/repomerge.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.merge 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoMergeNSID = "sh.tangled.repo.merge" 15 + ) 16 + 17 + // RepoMerge_Input is the input argument to a sh.tangled.repo.merge call. 18 + type RepoMerge_Input struct { 19 + // authorEmail: Author email for the merge commit 20 + AuthorEmail *string `json:"authorEmail,omitempty" cborgen:"authorEmail,omitempty"` 21 + // authorName: Author name for the merge commit 22 + AuthorName *string `json:"authorName,omitempty" cborgen:"authorName,omitempty"` 23 + // branch: Target branch to merge into 24 + Branch string `json:"branch" cborgen:"branch"` 25 + // commitBody: Additional commit message body 26 + CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"` 27 + // commitMessage: Merge commit message 28 + CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"` 29 + // did: DID of the repository owner 30 + Did string `json:"did" cborgen:"did"` 31 + // name: Name of the repository 32 + Name string `json:"name" cborgen:"name"` 33 + // patch: Patch content to merge 34 + Patch string `json:"patch" cborgen:"patch"` 35 + } 36 + 37 + // RepoMerge calls the XRPC method "sh.tangled.repo.merge". 38 + func RepoMerge(ctx context.Context, c util.LexClient, input *RepoMerge_Input) error { 39 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.merge", nil, input, nil); err != nil { 40 + return err 41 + } 42 + 43 + return nil 44 + }
+57
api/tangled/repomergeCheck.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.mergeCheck 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoMergeCheckNSID = "sh.tangled.repo.mergeCheck" 15 + ) 16 + 17 + // RepoMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.repo.mergeCheck schema. 18 + type RepoMergeCheck_ConflictInfo struct { 19 + // filename: Name of the conflicted file 20 + Filename string `json:"filename" cborgen:"filename"` 21 + // reason: Reason for the conflict 22 + Reason string `json:"reason" cborgen:"reason"` 23 + } 24 + 25 + // RepoMergeCheck_Input is the input argument to a sh.tangled.repo.mergeCheck call. 26 + type RepoMergeCheck_Input struct { 27 + // branch: Target branch to merge into 28 + Branch string `json:"branch" cborgen:"branch"` 29 + // did: DID of the repository owner 30 + Did string `json:"did" cborgen:"did"` 31 + // name: Name of the repository 32 + Name string `json:"name" cborgen:"name"` 33 + // patch: Patch or pull request to check for merge conflicts 34 + Patch string `json:"patch" cborgen:"patch"` 35 + } 36 + 37 + // RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call. 38 + type RepoMergeCheck_Output struct { 39 + // conflicts: List of files with merge conflicts 40 + Conflicts []*RepoMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"` 41 + // error: Error message if check failed 42 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 43 + // is_conflicted: Whether the merge has conflicts 44 + Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"` 45 + // message: Additional message about the merge check 46 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 47 + } 48 + 49 + // RepoMergeCheck calls the XRPC method "sh.tangled.repo.mergeCheck". 50 + func RepoMergeCheck(ctx context.Context, c util.LexClient, input *RepoMergeCheck_Input) (*RepoMergeCheck_Output, error) { 51 + var out RepoMergeCheck_Output 52 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.mergeCheck", nil, input, &out); err != nil { 53 + return nil, err 54 + } 55 + 56 + return &out, nil 57 + }
+24
lexicons/knot/knot.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }
+33
lexicons/repo/create.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.create", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a new repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "rkey" 14 + ], 15 + "properties": { 16 + "rkey": { 17 + "type": "string", 18 + "description": "Rkey of the repository record" 19 + }, 20 + "defaultBranch": { 21 + "type": "string", 22 + "description": "Default branch to push to" 23 + }, 24 + "source": { 25 + "type": "string", 26 + "description": "A source URL to clone from, populate this when forking or importing a repository." 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+53
lexicons/repo/forkStatus.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.forkStatus", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check fork status relative to upstream source", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "source", "branch", "hiddenRef"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the fork owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the forked repository" 22 + }, 23 + "source": { 24 + "type": "string", 25 + "description": "Source repository URL" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Branch to check status for" 30 + }, 31 + "hiddenRef": { 32 + "type": "string", 33 + "description": "Hidden ref to use for comparison" 34 + } 35 + } 36 + } 37 + }, 38 + "output": { 39 + "encoding": "application/json", 40 + "schema": { 41 + "type": "object", 42 + "required": ["status"], 43 + "properties": { 44 + "status": { 45 + "type": "integer", 46 + "description": "Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch" 47 + } 48 + } 49 + } 50 + } 51 + } 52 + } 53 + }
+42
lexicons/repo/forkSync.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.forkSync", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Sync a forked repository with its upstream source", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "did", 14 + "source", 15 + "name", 16 + "branch" 17 + ], 18 + "properties": { 19 + "did": { 20 + "type": "string", 21 + "format": "did", 22 + "description": "DID of the fork owner" 23 + }, 24 + "source": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "AT-URI of the source repository" 28 + }, 29 + "name": { 30 + "type": "string", 31 + "description": "Name of the forked repository" 32 + }, 33 + "branch": { 34 + "type": "string", 35 + "description": "Branch to sync" 36 + } 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+59
lexicons/repo/hiddenRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.hiddenRef", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a hidden ref in a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "forkRef", 15 + "remoteRef" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri", 21 + "description": "AT-URI of the repository" 22 + }, 23 + "forkRef": { 24 + "type": "string", 25 + "description": "Fork reference name" 26 + }, 27 + "remoteRef": { 28 + "type": "string", 29 + "description": "Remote reference name" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": [ 39 + "success" 40 + ], 41 + "properties": { 42 + "success": { 43 + "type": "boolean", 44 + "description": "Whether the hidden ref was created successfully" 45 + }, 46 + "ref": { 47 + "type": "string", 48 + "description": "The created hidden ref name" 49 + }, 50 + "error": { 51 + "type": "string", 52 + "description": "Error message if creation failed" 53 + } 54 + } 55 + } 56 + } 57 + } 58 + } 59 + }
+52
lexicons/repo/merge.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.merge", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Merge a patch into a repository branch", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch content to merge" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + }, 31 + "authorName": { 32 + "type": "string", 33 + "description": "Author name for the merge commit" 34 + }, 35 + "authorEmail": { 36 + "type": "string", 37 + "description": "Author email for the merge commit" 38 + }, 39 + "commitBody": { 40 + "type": "string", 41 + "description": "Additional commit message body" 42 + }, 43 + "commitMessage": { 44 + "type": "string", 45 + "description": "Merge commit message" 46 + } 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+79
lexicons/repo/mergeCheck.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.mergeCheck", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check if a merge is possible between two branches", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch or pull request to check for merge conflicts" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": ["is_conflicted"], 39 + "properties": { 40 + "is_conflicted": { 41 + "type": "boolean", 42 + "description": "Whether the merge has conflicts" 43 + }, 44 + "conflicts": { 45 + "type": "array", 46 + "description": "List of files with merge conflicts", 47 + "items": { 48 + "type": "ref", 49 + "ref": "#conflictInfo" 50 + } 51 + }, 52 + "message": { 53 + "type": "string", 54 + "description": "Additional message about the merge check" 55 + }, 56 + "error": { 57 + "type": "string", 58 + "description": "Error message if check failed" 59 + } 60 + } 61 + } 62 + } 63 + }, 64 + "conflictInfo": { 65 + "type": "object", 66 + "required": ["filename", "reason"], 67 + "properties": { 68 + "filename": { 69 + "type": "string", 70 + "description": "Name of the conflicted file" 71 + }, 72 + "reason": { 73 + "type": "string", 74 + "description": "Reason for the conflict" 75 + } 76 + } 77 + } 78 + } 79 + }
+22
api/tangled/tangledknot.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + KnotNSID = "sh.tangled.knot" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.knot", &Knot{}) 17 + } // 18 + // RECORDTYPE: Knot 19 + type Knot struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.knot" cborgen:"$type,const=sh.tangled.knot"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + }
+93 -28
appview/pages/templates/knots/dashboard.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }}{{ end }} 1 + {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <div class="flex justify-between items-center"> 6 - <div id="left-side" class="flex gap-2 items-center"> 7 - <h1 class="text-xl font-bold dark:text-white"> 8 - {{ .Registration.Domain }} 9 - </h1> 10 - <span class="text-gray-500 text-base"> 11 - {{ template "repo/fragments/shortTimeAgo" .Registration.Created }} 12 - </span> 13 - </div> 14 - <div id="right-side" class="flex gap-2"> 15 - {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 16 - {{ if .Registration.Registered }} 17 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 4 + <div class="px-6 py-4"> 5 + <div class="flex justify-between items-center"> 6 + <h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1> 7 + <div id="right-side" class="flex gap-2"> 8 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 + {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }} 10 + {{ if .Registration.IsRegistered }} 11 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 12 + {{ if $isOwner }} 18 13 {{ template "knots/fragments/addMemberModal" .Registration }} 19 - {{ else }} 20 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 21 14 {{ end }} 22 - </div> 15 + {{ else if .Registration.IsReadOnly }} 16 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 17 + {{ i "shield-alert" "w-4 h-4" }} read-only 18 + </span> 19 + {{ if $isOwner }} 20 + {{ block "retryButton" .Registration }} {{ end }} 21 + {{ end }} 22 + {{ else }} 23 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 24 + {{ if $isOwner }} 25 + {{ block "retryButton" .Registration }} {{ end }} 26 + {{ end }} 27 + {{ end }} 28 + 29 + {{ if $isOwner }} 30 + {{ block "deleteButton" .Registration }} {{ end }} 31 + {{ end }} 23 32 </div> 24 - <div id="operation-error" class="dark:text-red-400"></div> 25 33 </div> 34 + <div id="operation-error" class="dark:text-red-400"></div> 35 + </div> 26 36 27 - {{ if .Members }} 28 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 29 - <div class="flex flex-col gap-2"> 30 - {{ block "knotMember" . }} {{ end }} 31 - </div> 32 - </section> 33 - {{ end }} 37 + {{ if .Members }} 38 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 39 + <div class="flex flex-col gap-2"> 40 + {{ block "member" . }} {{ end }} 41 + </div> 42 + </section> 43 + {{ end }} 34 44 {{ end }} 35 45 36 - {{ define "knotMember" }} 46 + 47 + {{ define "member" }} 37 48 {{ range .Members }} 38 49 <div> 39 50 <div class="flex justify-between items-center"> ··· 41 52 {{ template "user/fragments/picHandleLink" . }} 42 53 <span class="ml-2 font-mono text-gray-500">{{.}}</span> 43 54 </div> 55 + {{ if ne $.LoggedInUser.Did . }} 56 + {{ block "removeMemberButton" (list $ . ) }} {{ end }} 57 + {{ end }} 44 58 </div> 45 59 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 46 60 {{ $repos := index $.Repos . }} ··· 53 67 </div> 54 68 {{ else }} 55 69 <div class="text-gray-500 dark:text-gray-400"> 56 - No repositories created yet. 70 + No repositories configured yet. 57 71 </div> 58 72 {{ end }} 59 73 </div> 60 74 </div> 61 75 {{ end }} 62 76 {{ end }} 77 + 78 + {{ define "deleteButton" }} 79 + <button 80 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 81 + title="Delete knot" 82 + hx-delete="/knots/{{ .Domain }}" 83 + hx-swap="outerHTML" 84 + hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 85 + hx-headers='{"shouldRedirect": "true"}' 86 + > 87 + {{ i "trash-2" "w-5 h-5" }} 88 + <span class="hidden md:inline">delete</span> 89 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 90 + </button> 91 + {{ end }} 92 + 93 + 94 + {{ define "retryButton" }} 95 + <button 96 + class="btn gap-2 group" 97 + title="Retry knot verification" 98 + hx-post="/knots/{{ .Domain }}/retry" 99 + hx-swap="none" 100 + hx-headers='{"shouldRefresh": "true"}' 101 + > 102 + {{ i "rotate-ccw" "w-5 h-5" }} 103 + <span class="hidden md:inline">retry</span> 104 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </button> 106 + {{ end }} 107 + 108 + 109 + {{ define "removeMemberButton" }} 110 + {{ $root := index . 0 }} 111 + {{ $member := index . 1 }} 112 + {{ $memberHandle := resolve $member }} 113 + <button 114 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 115 + title="Remove member" 116 + hx-post="/knots/{{ $root.Registration.Domain }}/remove" 117 + hx-swap="none" 118 + hx-vals='{"member": "{{$member}}" }' 119 + hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?" 120 + > 121 + {{ i "user-minus" "w-4 h-4" }} 122 + remove 123 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 124 + </button> 125 + {{ end }} 126 + 127 +
+6 -7
appview/pages/templates/knots/fragments/addMemberModal.html
··· 1 1 {{ define "knots/fragments/addMemberModal" }} 2 2 <button 3 3 class="btn gap-2 group" 4 - title="Add member to this spindle" 4 + title="Add member to this knot" 5 5 popovertarget="add-member-{{ .Id }}" 6 6 popovertargetaction="toggle" 7 7 > ··· 20 20 21 21 {{ define "addKnotMemberPopover" }} 22 22 <form 23 - hx-put="/knots/{{ .Domain }}/member" 23 + hx-post="/knots/{{ .Domain }}/add" 24 24 hx-indicator="#spinner" 25 25 hx-swap="none" 26 26 class="flex flex-col gap-2" ··· 28 28 <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 29 ADD MEMBER 30 30 </label> 31 - <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p> 31 + <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 32 32 <input 33 33 type="text" 34 34 id="member-did-{{ .Id }}" 35 - name="subject" 35 + name="member" 36 36 required 37 37 placeholder="@foo.bsky.social" 38 38 /> 39 39 <div class="flex gap-2 pt-2"> 40 - <button 40 + <button 41 41 type="button" 42 42 popovertarget="add-member-{{ .Id }}" 43 43 popovertargetaction="hide" ··· 54 54 </div> 55 55 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 56 </form> 57 - {{ end }} 58 - 57 + {{ end }}
+2 -2
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 16 class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 - {{ block "addMemberPopover" . }} {{ end }} 17 + {{ block "addSpindleMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }} 20 20 21 - {{ define "addMemberPopover" }} 21 + {{ define "addSpindleMemberPopover" }} 22 22 <form 23 23 hx-post="/spindles/{{ .Instance }}/add" 24 24 hx-indicator="#spinner"
-7
nix/modules/knot.nix
··· 99 99 description = "DID of owner (required)"; 100 100 }; 101 101 102 - secretFile = mkOption { 103 - type = lib.types.path; 104 - example = "KNOT_SERVER_SECRET=<hash>"; 105 - description = "File containing secret key provided by appview (required)"; 106 - }; 107 - 108 102 dbPath = mkOption { 109 103 type = types.path; 110 104 default = "${cfg.stateDir}/knotserver.db"; ··· 207 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 208 202 "KNOT_SERVER_OWNER=${cfg.server.owner}" 209 203 ]; 210 - EnvironmentFile = cfg.server.secretFile; 211 204 ExecStart = "${cfg.package}/bin/knot server"; 212 205 Restart = "always"; 213 206 };
+7
appview/pages/templates/layouts/topbar.html
··· 21 21 </div> 22 22 </div> 23 23 </nav> 24 + {{ if .LoggedInUser }} 25 + <div id="upgrade-banner" 26 + hx-get="/knots/upgradeBanner" 27 + hx-trigger="load" 28 + hx-swap="innerHTML"> 29 + </div> 30 + {{ end }} 24 31 {{ end }} 25 32 26 33 {{ define "newButton" }}
+2 -2
rbac/rbac.go
··· 281 281 return e.E.Enforce(user, domain, domain, "repo:create") 282 282 } 283 283 284 - func (e *Enforcer) IsRepoDeleteAllowed(user, domain string) (bool, error) { 285 - return e.E.Enforce(user, domain, domain, "repo:delete") 284 + func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) { 285 + return e.E.Enforce(user, domain, repo, "repo:delete") 286 286 } 287 287 288 288 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
+4 -7
api/tangled/pullcomment.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoPullComment 19 19 type RepoPullComment struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 - Body string `json:"body" cborgen:"body"` 22 - CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 - Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"` 25 - Pull string `json:"pull" cborgen:"pull"` 26 - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Pull string `json:"pull" cborgen:"pull"` 27 24 }
-11
lexicons/pulls/comment.json
··· 19 19 "type": "string", 20 20 "format": "at-uri" 21 21 }, 22 - "repo": { 23 - "type": "string", 24 - "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 - }, 29 - "owner": { 30 - "type": "string", 31 - "format": "did" 32 - }, 33 22 "body": { 34 23 "type": "string" 35 24 },
+7 -2
api/tangled/repopull.go
··· 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 23 Patch string `json:"patch" cborgen:"patch"` 24 24 Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 25 - TargetBranch string `json:"targetBranch" cborgen:"targetBranch"` 26 - TargetRepo string `json:"targetRepo" cborgen:"targetRepo"` 25 + Target *RepoPull_Target `json:"target" cborgen:"target"` 27 26 Title string `json:"title" cborgen:"title"` 28 27 } 29 28 ··· 33 32 Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 34 33 Sha string `json:"sha" cborgen:"sha"` 35 34 } 35 + 36 + // RepoPull_Target is a "target" in the sh.tangled.repo.pull schema. 37 + type RepoPull_Target struct { 38 + Branch string `json:"branch" cborgen:"branch"` 39 + Repo string `json:"repo" cborgen:"repo"` 40 + }
+20 -8
lexicons/pulls/pull.json
··· 10 10 "record": { 11 11 "type": "object", 12 12 "required": [ 13 - "targetRepo", 14 - "targetBranch", 13 + "target", 15 14 "title", 16 15 "patch", 17 16 "createdAt" 18 17 ], 19 18 "properties": { 20 - "targetRepo": { 21 - "type": "string", 22 - "format": "at-uri" 23 - }, 24 - "targetBranch": { 25 - "type": "string" 19 + "target": { 20 + "type": "ref", 21 + "ref": "#target" 26 22 }, 27 23 "title": { 28 24 "type": "string" ··· 44 40 } 45 41 } 46 42 }, 43 + "target": { 44 + "type": "object", 45 + "required": [ 46 + "repo", 47 + "branch" 48 + ], 49 + "properties": { 50 + "repo": { 51 + "type": "string", 52 + "format": "at-uri" 53 + }, 54 + "branch": { 55 + "type": "string" 56 + } 57 + } 58 + }, 47 59 "source": { 48 60 "type": "object", 49 61 "required": [
+2 -2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
··· 19 19 > 20 20 <option disabled selected>select a fork</option> 21 21 {{ range .Forks }} 22 - <option value="{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 23 - {{ .Name }} 22 + <option value="{{ .Did }}/{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 23 + {{ .Did | resolve }}/{{ .Name }} 24 24 </option> 25 25 {{ end }} 26 26 </select>
-2
api/tangled/repoissue.go
··· 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - IssueId int64 `json:"issueId" cborgen:"issueId"` 24 - Owner string `json:"owner" cborgen:"owner"` 25 23 Repo string `json:"repo" cborgen:"repo"` 26 24 Title string `json:"title" cborgen:"title"` 27 25 }
+1 -14
lexicons/issue/issue.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "repo", 14 - "issueId", 15 - "owner", 16 - "title", 17 - "createdAt" 18 - ], 12 + "required": ["repo", "title", "createdAt"], 19 13 "properties": { 20 14 "repo": { 21 15 "type": "string", 22 16 "format": "at-uri" 23 17 }, 24 - "issueId": { 25 - "type": "integer" 26 - }, 27 - "owner": { 28 - "type": "string", 29 - "format": "did" 30 - }, 31 18 "title": { 32 19 "type": "string" 33 20 },
+16
nix/modules/spindle.nix
··· 55 55 description = "DID of owner (required)"; 56 56 }; 57 57 58 + maxJobCount = mkOption { 59 + type = types.int; 60 + default = 2; 61 + example = 5; 62 + description = "Maximum number of concurrent jobs to run"; 63 + }; 64 + 65 + queueSize = mkOption { 66 + type = types.int; 67 + default = 100; 68 + example = 100; 69 + description = "Maximum number of jobs queue up"; 70 + }; 71 + 58 72 secrets = { 59 73 provider = mkOption { 60 74 type = types.str; ··· 108 122 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 109 123 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 110 124 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 125 + "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}" 126 + "SPINDLE_SERVER_QUEUE_SIZE=${toString cfg.server.queueSize}" 111 127 "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 112 128 "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 113 129 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
+4
appview/pages/templates/repo/fragments/duration.html
··· 1 + {{ define "repo/fragments/duration" }} 2 + <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 3 + {{ end }} 4 +
+4
appview/pages/templates/repo/fragments/shortTime.html
··· 1 + {{ define "repo/fragments/shortTime" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 3 + {{ end }} 4 +
-16
appview/pages/templates/repo/fragments/time.html
··· 1 - {{ define "repo/fragments/timeWrapper" }} 2 - <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 - {{ end }} 4 - 5 1 {{ define "repo/fragments/time" }} 6 2 {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 7 3 {{ end }} 8 - 9 - {{ define "repo/fragments/shortTime" }} 10 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 11 - {{ end }} 12 - 13 - {{ define "repo/fragments/shortTimeAgo" }} 14 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 15 - {{ end }} 16 - 17 - {{ define "repo/fragments/duration" }} 18 - <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 19 - {{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
··· 1 + {{ define "repo/fragments/timeWrapper" }} 2 + <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 + {{ end }} 4 + 5 +
+1 -1
appview/pages/templates/repo/pulls/fragments/pullStack.html
··· 52 52 </div> 53 53 {{ end }} 54 54 <div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2"> 55 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 55 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 56 56 </div> 57 57 </div> 58 58 </a>
+35
appview/pages/cache.go
··· 1 + package pages 2 + 3 + import ( 4 + "sync" 5 + ) 6 + 7 + type TmplCache[K comparable, V any] struct { 8 + data map[K]V 9 + mutex sync.RWMutex 10 + } 11 + 12 + func NewTmplCache[K comparable, V any]() *TmplCache[K, V] { 13 + return &TmplCache[K, V]{ 14 + data: make(map[K]V), 15 + } 16 + } 17 + 18 + func (c *TmplCache[K, V]) Get(key K) (V, bool) { 19 + c.mutex.RLock() 20 + defer c.mutex.RUnlock() 21 + val, exists := c.data[key] 22 + return val, exists 23 + } 24 + 25 + func (c *TmplCache[K, V]) Set(key K, value V) { 26 + c.mutex.Lock() 27 + defer c.mutex.Unlock() 28 + c.data[key] = value 29 + } 30 + 31 + func (c *TmplCache[K, V]) Size() int { 32 + c.mutex.RLock() 33 + defer c.mutex.RUnlock() 34 + return len(c.data) 35 + }
+1 -1
appview/pages/templates/user/fragments/editBio.html
··· 13 13 <label class="m-0 p-0" for="description">bio</label> 14 14 <textarea 15 15 type="text" 16 - class="py-1 px-1 w-full" 16 + class="p-2 w-full" 17 17 name="description" 18 18 rows="3" 19 19 placeholder="write a bio">{{ $description }}</textarea>
+7 -18
appview/pages/templates/user/repos.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · repos {{ end }} 2 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "ownRepos" . }}{{ end }} 17 - </div> 18 - </div> 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "ownRepos" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "ownRepos" }} 22 - <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 23 10 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 24 11 {{ range .Repos }} 25 - {{ template "user/fragments/repoCard" (list $ . false) }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . false) }} 14 + </div> 26 15 {{ else }} 27 16 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 28 17 {{ end }}
+19
appview/pages/templates/user/starred.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · repos {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "starredRepos" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "starredRepos" }} 10 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Repos }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . true) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any starred repos yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }}
+2 -2
appview/pages/templates/user/fragments/profileCard.html
··· 92 92 {{ with $root }} 93 93 <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 94 94 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 95 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 95 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 96 96 <span class="select-none after:content-['·']"></span> 97 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 97 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 98 98 </div> 99 99 {{ end }} 100 100 {{ end }}
+45
appview/pages/templates/user/strings.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · strings {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "allStrings" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "allStrings" }} 10 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Strings }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "singleString" (list $ .) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "singleString" }} 22 + {{ $root := index . 0 }} 23 + {{ $s := index . 1 }} 24 + <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 25 + <div class="font-medium dark:text-white flex gap-2 items-center"> 26 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 27 + </div> 28 + {{ with $s.Description }} 29 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 30 + {{ . }} 31 + </div> 32 + {{ end }} 33 + 34 + {{ $stat := $s.Stats }} 35 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 36 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 37 + <span class="select-none [&:before]:content-['·']"></span> 38 + {{ with $s.Edited }} 39 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 40 + {{ else }} 41 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 42 + {{ end }} 43 + </div> 44 + </div> 45 + {{ end }}
+22 -2
knotserver/routes.go
··· 156 156 } 157 157 158 158 var modVer string 159 + var sha string 160 + var modified bool 161 + 159 162 for _, mod := range info.Deps { 160 163 if mod.Path == "tangled.sh/tangled.sh/knotserver" { 161 - version = mod.Version 164 + modVer = mod.Version 162 165 break 163 166 } 164 167 } 165 168 169 + for _, setting := range info.Settings { 170 + switch setting.Key { 171 + case "vcs.revision": 172 + sha = setting.Value 173 + case "vcs.modified": 174 + modified = setting.Value == "true" 175 + } 176 + } 177 + 166 178 if modVer == "" { 167 - version = "unknown" 179 + modVer = "unknown" 180 + } 181 + 182 + if sha == "" { 183 + version = modVer 184 + } else if modified { 185 + version = fmt.Sprintf("%s (%s with modifications)", modVer, sha) 186 + } else { 187 + version = fmt.Sprintf("%s (%s)", modVer, sha) 168 188 } 169 189 } 170 190
+1 -1
patchutil/combinediff.go
··· 119 119 // we have f1 and f2, combine them 120 120 combined, err := combineFiles(f1, f2) 121 121 if err != nil { 122 - fmt.Println(err) 122 + // fmt.Println(err) 123 123 } 124 124 125 125 // combined can be nil commit 2 reverted all changes from commit 1
+1 -1
appview/pages/templates/errors/knot404.html
··· 17 17 The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 18 </p> 19 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline"> 20 + <a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline"> 21 21 {{ i "arrow-left" "w-4 h-4" }} 22 22 back to timeline 23 23 </a>
+25
appview/pages/templates/timeline/fragments/trending.html
··· 1 + {{ define "timeline/fragments/trending" }} 2 + <div class="w-full md:mx-0 py-4"> 3 + <div class="px-6 pb-4"> 4 + <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 5 + Trending 6 + {{ i "trending-up" "size-4 flex-shrink-0" }} 7 + </h3> 8 + </div> 9 + <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 10 + {{ range $index, $repo := .Repos }} 11 + <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 12 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 13 + </div> 14 + {{ else }} 15 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 16 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 17 + No trending repositories this week 18 + </div> 19 + </div> 20 + {{ end }} 21 + </div> 22 + </div> 23 + {{ end }} 24 + 25 +
+109
legal/terms.md
··· 1 + # Terms of Service 2 + 3 + **Last updated:** January 15, 2025 4 + 5 + Welcome to Tangled. These Terms of Service ("Terms") govern your access 6 + to and use of the Tangled platform and services (the "Service") 7 + operated by us ("Tangled," "we," "us," or "our"). 8 + 9 + ## 1. Acceptance of Terms 10 + 11 + By accessing or using our Service, you agree to be bound by these Terms. 12 + If you disagree with any part of these terms, then you may not access 13 + the Service. 14 + 15 + ## 2. Account Registration 16 + 17 + To use certain features of the Service, you must register for an 18 + account. You agree to provide accurate, current, and complete 19 + information during the registration process and to update such 20 + information to keep it accurate, current, and complete. 21 + 22 + ## 3. Account Termination 23 + 24 + > **Important Notice** 25 + > 26 + > **We reserve the right to terminate, suspend, or restrict access to 27 + > your account at any time, for any reason, or for no reason at all, at 28 + > our sole discretion.** This includes, but is not limited to, 29 + > termination for violation of these Terms, inappropriate conduct, spam, 30 + > abuse, or any other behavior we deem harmful to the Service or other 31 + > users. 32 + > 33 + > Account termination may result in the loss of access to your 34 + > repositories, data, and other content associated with your account. We 35 + > are not obligated to provide advance notice of termination, though we 36 + > may do so in our discretion. 37 + 38 + ## 4. Acceptable Use 39 + 40 + You agree not to use the Service to: 41 + 42 + - Violate any applicable laws or regulations 43 + - Infringe upon the rights of others 44 + - Upload, store, or share content that is illegal, harmful, threatening, 45 + abusive, harassing, defamatory, vulgar, obscene, or otherwise 46 + objectionable 47 + - Engage in spam, phishing, or other deceptive practices 48 + - Attempt to gain unauthorized access to the Service or other users' 49 + accounts 50 + - Interfere with or disrupt the Service or servers connected to the 51 + Service 52 + 53 + ## 5. Content and Intellectual Property 54 + 55 + You retain ownership of the content you upload to the Service. By 56 + uploading content, you grant us a non-exclusive, worldwide, royalty-free 57 + license to use, reproduce, modify, and distribute your content as 58 + necessary to provide the Service. 59 + 60 + ## 6. Privacy 61 + 62 + Your privacy is important to us. Please review our [Privacy 63 + Policy](/privacy), which also governs your use of the Service. 64 + 65 + ## 7. Disclaimers 66 + 67 + The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 68 + no warranties, expressed or implied, and hereby disclaim and negate all 69 + other warranties including without limitation, implied warranties or 70 + conditions of merchantability, fitness for a particular purpose, or 71 + non-infringement of intellectual property or other violation of rights. 72 + 73 + ## 8. Limitation of Liability 74 + 75 + In no event shall Tangled, nor its directors, employees, partners, 76 + agents, suppliers, or affiliates, be liable for any indirect, 77 + incidental, special, consequential, or punitive damages, including 78 + without limitation, loss of profits, data, use, goodwill, or other 79 + intangible losses, resulting from your use of the Service. 80 + 81 + ## 9. Indemnification 82 + 83 + You agree to defend, indemnify, and hold harmless Tangled and its 84 + affiliates, officers, directors, employees, and agents from and against 85 + any and all claims, damages, obligations, losses, liabilities, costs, 86 + or debt, and expenses (including attorney's fees). 87 + 88 + ## 10. Governing Law 89 + 90 + These Terms shall be interpreted and governed by the laws of Finland, 91 + without regard to its conflict of law provisions. 92 + 93 + ## 11. Changes to Terms 94 + 95 + We reserve the right to modify or replace these Terms at any time. If a 96 + revision is material, we will try to provide at least 30 days notice 97 + prior to any new terms taking effect. 98 + 99 + ## 12. Contact Information 100 + 101 + If you have any questions about these Terms of Service, please contact 102 + us through our platform or via email. 103 + 104 + --- 105 + 106 + These terms are effective as of the last updated date shown above and 107 + will remain in effect except with respect to any changes in their 108 + provisions in the future, which will be in effect immediately after 109 + being posted on this page.
+53
api/tangled/knotlistKeys.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot.listKeys 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + KnotListKeysNSID = "sh.tangled.knot.listKeys" 15 + ) 16 + 17 + // KnotListKeys_Output is the output of a sh.tangled.knot.listKeys call. 18 + type KnotListKeys_Output struct { 19 + // cursor: Pagination cursor for next page 20 + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` 21 + Keys []*KnotListKeys_PublicKey `json:"keys" cborgen:"keys"` 22 + } 23 + 24 + // KnotListKeys_PublicKey is a "publicKey" in the sh.tangled.knot.listKeys schema. 25 + type KnotListKeys_PublicKey struct { 26 + // createdAt: Key upload timestamp 27 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 28 + // did: DID associated with the public key 29 + Did string `json:"did" cborgen:"did"` 30 + // key: Public key contents 31 + Key string `json:"key" cborgen:"key"` 32 + } 33 + 34 + // KnotListKeys calls the XRPC method "sh.tangled.knot.listKeys". 35 + // 36 + // cursor: Pagination cursor 37 + // limit: Maximum number of keys to return 38 + func KnotListKeys(ctx context.Context, c util.LexClient, cursor string, limit int64) (*KnotListKeys_Output, error) { 39 + var out KnotListKeys_Output 40 + 41 + params := map[string]interface{}{} 42 + if cursor != "" { 43 + params["cursor"] = cursor 44 + } 45 + if limit != 0 { 46 + params["limit"] = limit 47 + } 48 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.listKeys", params, nil, &out); err != nil { 49 + return nil, err 50 + } 51 + 52 + return &out, nil 53 + }
+41
api/tangled/repoarchive.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.archive 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoArchiveNSID = "sh.tangled.repo.archive" 16 + ) 17 + 18 + // RepoArchive calls the XRPC method "sh.tangled.repo.archive". 19 + // 20 + // format: Archive format 21 + // prefix: Prefix for files in the archive 22 + // ref: Git reference (branch, tag, or commit SHA) 23 + // repo: Repository identifier in format 'did:plc:.../repoName' 24 + func RepoArchive(ctx context.Context, c util.LexClient, format string, prefix string, ref string, repo string) ([]byte, error) { 25 + buf := new(bytes.Buffer) 26 + 27 + params := map[string]interface{}{} 28 + if format != "" { 29 + params["format"] = format 30 + } 31 + if prefix != "" { 32 + params["prefix"] = prefix 33 + } 34 + params["ref"] = ref 35 + params["repo"] = repo 36 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.archive", params, nil, buf); err != nil { 37 + return nil, err 38 + } 39 + 40 + return buf.Bytes(), nil 41 + }
+80
api/tangled/repoblob.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.blob 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoBlobNSID = "sh.tangled.repo.blob" 15 + ) 16 + 17 + // RepoBlob_LastCommit is a "lastCommit" in the sh.tangled.repo.blob schema. 18 + type RepoBlob_LastCommit struct { 19 + Author *RepoBlob_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Commit hash 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // message: Commit message 23 + Message string `json:"message" cborgen:"message"` 24 + // shortHash: Short commit hash 25 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 26 + // when: Commit timestamp 27 + When string `json:"when" cborgen:"when"` 28 + } 29 + 30 + // RepoBlob_Output is the output of a sh.tangled.repo.blob call. 31 + type RepoBlob_Output struct { 32 + // content: File content (base64 encoded for binary files) 33 + Content string `json:"content" cborgen:"content"` 34 + // encoding: Content encoding 35 + Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` 36 + // isBinary: Whether the file is binary 37 + IsBinary *bool `json:"isBinary,omitempty" cborgen:"isBinary,omitempty"` 38 + LastCommit *RepoBlob_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"` 39 + // mimeType: MIME type of the file 40 + MimeType *string `json:"mimeType,omitempty" cborgen:"mimeType,omitempty"` 41 + // path: The file path 42 + Path string `json:"path" cborgen:"path"` 43 + // ref: The git reference used 44 + Ref string `json:"ref" cborgen:"ref"` 45 + // size: File size in bytes 46 + Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"` 47 + } 48 + 49 + // RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema. 50 + type RepoBlob_Signature struct { 51 + // email: Author email 52 + Email string `json:"email" cborgen:"email"` 53 + // name: Author name 54 + Name string `json:"name" cborgen:"name"` 55 + // when: Author timestamp 56 + When string `json:"when" cborgen:"when"` 57 + } 58 + 59 + // RepoBlob calls the XRPC method "sh.tangled.repo.blob". 60 + // 61 + // path: Path to the file within the repository 62 + // raw: Return raw file content instead of JSON response 63 + // ref: Git reference (branch, tag, or commit SHA) 64 + // repo: Repository identifier in format 'did:plc:.../repoName' 65 + func RepoBlob(ctx context.Context, c util.LexClient, path string, raw bool, ref string, repo string) (*RepoBlob_Output, error) { 66 + var out RepoBlob_Output 67 + 68 + params := map[string]interface{}{} 69 + params["path"] = path 70 + if raw { 71 + params["raw"] = raw 72 + } 73 + params["ref"] = ref 74 + params["repo"] = repo 75 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.blob", params, nil, &out); err != nil { 76 + return nil, err 77 + } 78 + 79 + return &out, nil 80 + }
+59
api/tangled/repobranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.branch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoBranchNSID = "sh.tangled.repo.branch" 15 + ) 16 + 17 + // RepoBranch_Output is the output of a sh.tangled.repo.branch call. 18 + type RepoBranch_Output struct { 19 + Author *RepoBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Latest commit hash on this branch 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // isDefault: Whether this is the default branch 23 + IsDefault *bool `json:"isDefault,omitempty" cborgen:"isDefault,omitempty"` 24 + // message: Latest commit message 25 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 26 + // name: Branch name 27 + Name string `json:"name" cborgen:"name"` 28 + // shortHash: Short commit hash 29 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 30 + // when: Timestamp of latest commit 31 + When string `json:"when" cborgen:"when"` 32 + } 33 + 34 + // RepoBranch_Signature is a "signature" in the sh.tangled.repo.branch schema. 35 + type RepoBranch_Signature struct { 36 + // email: Author email 37 + Email string `json:"email" cborgen:"email"` 38 + // name: Author name 39 + Name string `json:"name" cborgen:"name"` 40 + // when: Author timestamp 41 + When string `json:"when" cborgen:"when"` 42 + } 43 + 44 + // RepoBranch calls the XRPC method "sh.tangled.repo.branch". 45 + // 46 + // name: Branch name to get information for 47 + // repo: Repository identifier in format 'did:plc:.../repoName' 48 + func RepoBranch(ctx context.Context, c util.LexClient, name string, repo string) (*RepoBranch_Output, error) { 49 + var out RepoBranch_Output 50 + 51 + params := map[string]interface{}{} 52 + params["name"] = name 53 + params["repo"] = repo 54 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branch", params, nil, &out); err != nil { 55 + return nil, err 56 + } 57 + 58 + return &out, nil 59 + }
+39
api/tangled/repobranches.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.branches 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoBranchesNSID = "sh.tangled.repo.branches" 16 + ) 17 + 18 + // RepoBranches calls the XRPC method "sh.tangled.repo.branches". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of branches to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoBranches(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + if cursor != "" { 28 + params["cursor"] = cursor 29 + } 30 + if limit != 0 { 31 + params["limit"] = limit 32 + } 33 + params["repo"] = repo 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branches", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+35
api/tangled/repocompare.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.compare 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoCompareNSID = "sh.tangled.repo.compare" 16 + ) 17 + 18 + // RepoCompare calls the XRPC method "sh.tangled.repo.compare". 19 + // 20 + // repo: Repository identifier in format 'did:plc:.../repoName' 21 + // rev1: First revision (commit, branch, or tag) 22 + // rev2: Second revision (commit, branch, or tag) 23 + func RepoCompare(ctx context.Context, c util.LexClient, repo string, rev1 string, rev2 string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + params["repo"] = repo 28 + params["rev1"] = rev1 29 + params["rev2"] = rev2 30 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.compare", params, nil, buf); err != nil { 31 + return nil, err 32 + } 33 + 34 + return buf.Bytes(), nil 35 + }
+33
api/tangled/repodiff.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.diff 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoDiffNSID = "sh.tangled.repo.diff" 16 + ) 17 + 18 + // RepoDiff calls the XRPC method "sh.tangled.repo.diff". 19 + // 20 + // ref: Git reference (branch, tag, or commit SHA) 21 + // repo: Repository identifier in format 'did:plc:.../repoName' 22 + func RepoDiff(ctx context.Context, c util.LexClient, ref string, repo string) ([]byte, error) { 23 + buf := new(bytes.Buffer) 24 + 25 + params := map[string]interface{}{} 26 + params["ref"] = ref 27 + params["repo"] = repo 28 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.diff", params, nil, buf); err != nil { 29 + return nil, err 30 + } 31 + 32 + return buf.Bytes(), nil 33 + }
+55
api/tangled/repogetDefaultBranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.getDefaultBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoGetDefaultBranchNSID = "sh.tangled.repo.getDefaultBranch" 15 + ) 16 + 17 + // RepoGetDefaultBranch_Output is the output of a sh.tangled.repo.getDefaultBranch call. 18 + type RepoGetDefaultBranch_Output struct { 19 + Author *RepoGetDefaultBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Latest commit hash on default branch 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // message: Latest commit message 23 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 24 + // name: Default branch name 25 + Name string `json:"name" cborgen:"name"` 26 + // shortHash: Short commit hash 27 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 28 + // when: Timestamp of latest commit 29 + When string `json:"when" cborgen:"when"` 30 + } 31 + 32 + // RepoGetDefaultBranch_Signature is a "signature" in the sh.tangled.repo.getDefaultBranch schema. 33 + type RepoGetDefaultBranch_Signature struct { 34 + // email: Author email 35 + Email string `json:"email" cborgen:"email"` 36 + // name: Author name 37 + Name string `json:"name" cborgen:"name"` 38 + // when: Author timestamp 39 + When string `json:"when" cborgen:"when"` 40 + } 41 + 42 + // RepoGetDefaultBranch calls the XRPC method "sh.tangled.repo.getDefaultBranch". 43 + // 44 + // repo: Repository identifier in format 'did:plc:.../repoName' 45 + func RepoGetDefaultBranch(ctx context.Context, c util.LexClient, repo string) (*RepoGetDefaultBranch_Output, error) { 46 + var out RepoGetDefaultBranch_Output 47 + 48 + params := map[string]interface{}{} 49 + params["repo"] = repo 50 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.getDefaultBranch", params, nil, &out); err != nil { 51 + return nil, err 52 + } 53 + 54 + return &out, nil 55 + }
+61
api/tangled/repolanguages.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.languages 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoLanguagesNSID = "sh.tangled.repo.languages" 15 + ) 16 + 17 + // RepoLanguages_Language is a "language" in the sh.tangled.repo.languages schema. 18 + type RepoLanguages_Language struct { 19 + // color: Hex color code for this language 20 + Color *string `json:"color,omitempty" cborgen:"color,omitempty"` 21 + // extensions: File extensions associated with this language 22 + Extensions []string `json:"extensions,omitempty" cborgen:"extensions,omitempty"` 23 + // fileCount: Number of files in this language 24 + FileCount *int64 `json:"fileCount,omitempty" cborgen:"fileCount,omitempty"` 25 + // name: Programming language name 26 + Name string `json:"name" cborgen:"name"` 27 + // percentage: Percentage of total codebase (0-100) 28 + Percentage int64 `json:"percentage" cborgen:"percentage"` 29 + // size: Total size of files in this language (bytes) 30 + Size int64 `json:"size" cborgen:"size"` 31 + } 32 + 33 + // RepoLanguages_Output is the output of a sh.tangled.repo.languages call. 34 + type RepoLanguages_Output struct { 35 + Languages []*RepoLanguages_Language `json:"languages" cborgen:"languages"` 36 + // ref: The git reference used 37 + Ref string `json:"ref" cborgen:"ref"` 38 + // totalFiles: Total number of files analyzed 39 + TotalFiles *int64 `json:"totalFiles,omitempty" cborgen:"totalFiles,omitempty"` 40 + // totalSize: Total size of all analyzed files in bytes 41 + TotalSize *int64 `json:"totalSize,omitempty" cborgen:"totalSize,omitempty"` 42 + } 43 + 44 + // RepoLanguages calls the XRPC method "sh.tangled.repo.languages". 45 + // 46 + // ref: Git reference (branch, tag, or commit SHA) 47 + // repo: Repository identifier in format 'did:plc:.../repoName' 48 + func RepoLanguages(ctx context.Context, c util.LexClient, ref string, repo string) (*RepoLanguages_Output, error) { 49 + var out RepoLanguages_Output 50 + 51 + params := map[string]interface{}{} 52 + if ref != "" { 53 + params["ref"] = ref 54 + } 55 + params["repo"] = repo 56 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.languages", params, nil, &out); err != nil { 57 + return nil, err 58 + } 59 + 60 + return &out, nil 61 + }
+45
api/tangled/repolog.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.log 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoLogNSID = "sh.tangled.repo.log" 16 + ) 17 + 18 + // RepoLog calls the XRPC method "sh.tangled.repo.log". 19 + // 20 + // cursor: Pagination cursor (commit SHA) 21 + // limit: Maximum number of commits to return 22 + // path: Path to filter commits by 23 + // ref: Git reference (branch, tag, or commit SHA) 24 + // repo: Repository identifier in format 'did:plc:.../repoName' 25 + func RepoLog(ctx context.Context, c util.LexClient, cursor string, limit int64, path string, ref string, repo string) ([]byte, error) { 26 + buf := new(bytes.Buffer) 27 + 28 + params := map[string]interface{}{} 29 + if cursor != "" { 30 + params["cursor"] = cursor 31 + } 32 + if limit != 0 { 33 + params["limit"] = limit 34 + } 35 + if path != "" { 36 + params["path"] = path 37 + } 38 + params["ref"] = ref 39 + params["repo"] = repo 40 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.log", params, nil, buf); err != nil { 41 + return nil, err 42 + } 43 + 44 + return buf.Bytes(), nil 45 + }
+39
api/tangled/repotags.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tags 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoTagsNSID = "sh.tangled.repo.tags" 16 + ) 17 + 18 + // RepoTags calls the XRPC method "sh.tangled.repo.tags". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of tags to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoTags(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + if cursor != "" { 28 + params["cursor"] = cursor 29 + } 30 + if limit != 0 { 31 + params["limit"] = limit 32 + } 33 + params["repo"] = repo 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tags", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+73
lexicons/knot/listKeys.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.listKeys", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List all public keys stored in the knot server", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "description": "Maximum number of keys to return", 14 + "minimum": 1, 15 + "maximum": 1000, 16 + "default": 100 17 + }, 18 + "cursor": { 19 + "type": "string", 20 + "description": "Pagination cursor" 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/json", 26 + "schema": { 27 + "type": "object", 28 + "required": ["keys"], 29 + "properties": { 30 + "keys": { 31 + "type": "array", 32 + "items": { 33 + "type": "ref", 34 + "ref": "#publicKey" 35 + } 36 + }, 37 + "cursor": { 38 + "type": "string", 39 + "description": "Pagination cursor for next page" 40 + } 41 + } 42 + } 43 + }, 44 + "errors": [ 45 + { 46 + "name": "InternalServerError", 47 + "description": "Failed to retrieve public keys" 48 + } 49 + ] 50 + }, 51 + "publicKey": { 52 + "type": "object", 53 + "required": ["did", "key", "createdAt"], 54 + "properties": { 55 + "did": { 56 + "type": "string", 57 + "format": "did", 58 + "description": "DID associated with the public key" 59 + }, 60 + "key": { 61 + "type": "string", 62 + "maxLength": 4096, 63 + "description": "Public key contents" 64 + }, 65 + "createdAt": { 66 + "type": "string", 67 + "format": "datetime", 68 + "description": "Key upload timestamp" 69 + } 70 + } 71 + } 72 + } 73 + }
+55
lexicons/repo/archive.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.archive", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "format": { 20 + "type": "string", 21 + "description": "Archive format", 22 + "enum": ["tar", "zip", "tar.gz", "tar.bz2", "tar.xz"], 23 + "default": "tar.gz" 24 + }, 25 + "prefix": { 26 + "type": "string", 27 + "description": "Prefix for files in the archive" 28 + } 29 + } 30 + }, 31 + "output": { 32 + "encoding": "*/*", 33 + "description": "Binary archive data" 34 + }, 35 + "errors": [ 36 + { 37 + "name": "RepoNotFound", 38 + "description": "Repository not found or access denied" 39 + }, 40 + { 41 + "name": "RefNotFound", 42 + "description": "Git reference not found" 43 + }, 44 + { 45 + "name": "InvalidRequest", 46 + "description": "Invalid request parameters" 47 + }, 48 + { 49 + "name": "ArchiveError", 50 + "description": "Failed to create archive" 51 + } 52 + ] 53 + } 54 + } 55 + }
+138
lexicons/repo/blob.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.blob", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref", "path"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path to the file within the repository" 22 + }, 23 + "raw": { 24 + "type": "boolean", 25 + "description": "Return raw file content instead of JSON response", 26 + "default": false 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["ref", "path", "content"], 35 + "properties": { 36 + "ref": { 37 + "type": "string", 38 + "description": "The git reference used" 39 + }, 40 + "path": { 41 + "type": "string", 42 + "description": "The file path" 43 + }, 44 + "content": { 45 + "type": "string", 46 + "description": "File content (base64 encoded for binary files)" 47 + }, 48 + "encoding": { 49 + "type": "string", 50 + "description": "Content encoding", 51 + "enum": ["utf-8", "base64"] 52 + }, 53 + "size": { 54 + "type": "integer", 55 + "description": "File size in bytes" 56 + }, 57 + "isBinary": { 58 + "type": "boolean", 59 + "description": "Whether the file is binary" 60 + }, 61 + "mimeType": { 62 + "type": "string", 63 + "description": "MIME type of the file" 64 + }, 65 + "lastCommit": { 66 + "type": "ref", 67 + "ref": "#lastCommit" 68 + } 69 + } 70 + } 71 + }, 72 + "errors": [ 73 + { 74 + "name": "RepoNotFound", 75 + "description": "Repository not found or access denied" 76 + }, 77 + { 78 + "name": "RefNotFound", 79 + "description": "Git reference not found" 80 + }, 81 + { 82 + "name": "FileNotFound", 83 + "description": "File not found at the specified path" 84 + }, 85 + { 86 + "name": "InvalidRequest", 87 + "description": "Invalid request parameters" 88 + } 89 + ] 90 + }, 91 + "lastCommit": { 92 + "type": "object", 93 + "required": ["hash", "message", "when"], 94 + "properties": { 95 + "hash": { 96 + "type": "string", 97 + "description": "Commit hash" 98 + }, 99 + "shortHash": { 100 + "type": "string", 101 + "description": "Short commit hash" 102 + }, 103 + "message": { 104 + "type": "string", 105 + "description": "Commit message" 106 + }, 107 + "author": { 108 + "type": "ref", 109 + "ref": "#signature" 110 + }, 111 + "when": { 112 + "type": "string", 113 + "format": "datetime", 114 + "description": "Commit timestamp" 115 + } 116 + } 117 + }, 118 + "signature": { 119 + "type": "object", 120 + "required": ["name", "email", "when"], 121 + "properties": { 122 + "name": { 123 + "type": "string", 124 + "description": "Author name" 125 + }, 126 + "email": { 127 + "type": "string", 128 + "description": "Author email" 129 + }, 130 + "when": { 131 + "type": "string", 132 + "format": "datetime", 133 + "description": "Author timestamp" 134 + } 135 + } 136 + } 137 + } 138 + }
+94
lexicons/repo/branch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.branch", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "name"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "name": { 16 + "type": "string", 17 + "description": "Branch name to get information for" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "required": ["name", "hash", "when"], 26 + "properties": { 27 + "name": { 28 + "type": "string", 29 + "description": "Branch name" 30 + }, 31 + "hash": { 32 + "type": "string", 33 + "description": "Latest commit hash on this branch" 34 + }, 35 + "shortHash": { 36 + "type": "string", 37 + "description": "Short commit hash" 38 + }, 39 + "when": { 40 + "type": "string", 41 + "format": "datetime", 42 + "description": "Timestamp of latest commit" 43 + }, 44 + "message": { 45 + "type": "string", 46 + "description": "Latest commit message" 47 + }, 48 + "author": { 49 + "type": "ref", 50 + "ref": "#signature" 51 + }, 52 + "isDefault": { 53 + "type": "boolean", 54 + "description": "Whether this is the default branch" 55 + } 56 + } 57 + } 58 + }, 59 + "errors": [ 60 + { 61 + "name": "RepoNotFound", 62 + "description": "Repository not found or access denied" 63 + }, 64 + { 65 + "name": "BranchNotFound", 66 + "description": "Branch not found" 67 + }, 68 + { 69 + "name": "InvalidRequest", 70 + "description": "Invalid request parameters" 71 + } 72 + ] 73 + }, 74 + "signature": { 75 + "type": "object", 76 + "required": ["name", "email", "when"], 77 + "properties": { 78 + "name": { 79 + "type": "string", 80 + "description": "Author name" 81 + }, 82 + "email": { 83 + "type": "string", 84 + "description": "Author email" 85 + }, 86 + "when": { 87 + "type": "string", 88 + "format": "datetime", 89 + "description": "Author timestamp" 90 + } 91 + } 92 + } 93 + } 94 + }
+43
lexicons/repo/branches.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.branches", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "description": "Maximum number of branches to return", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 50 21 + }, 22 + "cursor": { 23 + "type": "string", 24 + "description": "Pagination cursor" 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "*/*" 30 + }, 31 + "errors": [ 32 + { 33 + "name": "RepoNotFound", 34 + "description": "Repository not found or access denied" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+49
lexicons/repo/compare.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.compare", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "rev1", "rev2"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "rev1": { 16 + "type": "string", 17 + "description": "First revision (commit, branch, or tag)" 18 + }, 19 + "rev2": { 20 + "type": "string", 21 + "description": "Second revision (commit, branch, or tag)" 22 + } 23 + } 24 + }, 25 + "output": { 26 + "encoding": "*/*", 27 + "description": "Compare output in application/json" 28 + }, 29 + "errors": [ 30 + { 31 + "name": "RepoNotFound", 32 + "description": "Repository not found or access denied" 33 + }, 34 + { 35 + "name": "RevisionNotFound", 36 + "description": "One or both revisions not found" 37 + }, 38 + { 39 + "name": "InvalidRequest", 40 + "description": "Invalid request parameters" 41 + }, 42 + { 43 + "name": "CompareError", 44 + "description": "Failed to compare revisions" 45 + } 46 + ] 47 + } 48 + } 49 + }
+40
lexicons/repo/diff.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.diff", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "*/*" 23 + }, 24 + "errors": [ 25 + { 26 + "name": "RepoNotFound", 27 + "description": "Repository not found or access denied" 28 + }, 29 + { 30 + "name": "RefNotFound", 31 + "description": "Git reference not found" 32 + }, 33 + { 34 + "name": "InvalidRequest", 35 + "description": "Invalid request parameters" 36 + } 37 + ] 38 + } 39 + } 40 + }
+82
lexicons/repo/getDefaultBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.getDefaultBranch", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + } 15 + } 16 + }, 17 + "output": { 18 + "encoding": "application/json", 19 + "schema": { 20 + "type": "object", 21 + "required": ["name", "hash", "when"], 22 + "properties": { 23 + "name": { 24 + "type": "string", 25 + "description": "Default branch name" 26 + }, 27 + "hash": { 28 + "type": "string", 29 + "description": "Latest commit hash on default branch" 30 + }, 31 + "shortHash": { 32 + "type": "string", 33 + "description": "Short commit hash" 34 + }, 35 + "when": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "Timestamp of latest commit" 39 + }, 40 + "message": { 41 + "type": "string", 42 + "description": "Latest commit message" 43 + }, 44 + "author": { 45 + "type": "ref", 46 + "ref": "#signature" 47 + } 48 + } 49 + } 50 + }, 51 + "errors": [ 52 + { 53 + "name": "RepoNotFound", 54 + "description": "Repository not found or access denied" 55 + }, 56 + { 57 + "name": "InvalidRequest", 58 + "description": "Invalid request parameters" 59 + } 60 + ] 61 + }, 62 + "signature": { 63 + "type": "object", 64 + "required": ["name", "email", "when"], 65 + "properties": { 66 + "name": { 67 + "type": "string", 68 + "description": "Author name" 69 + }, 70 + "email": { 71 + "type": "string", 72 + "description": "Author email" 73 + }, 74 + "when": { 75 + "type": "string", 76 + "format": "datetime", 77 + "description": "Author timestamp" 78 + } 79 + } 80 + } 81 + } 82 + }
+99
lexicons/repo/languages.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.languages", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)", 18 + "default": "HEAD" 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["ref", "languages"], 27 + "properties": { 28 + "ref": { 29 + "type": "string", 30 + "description": "The git reference used" 31 + }, 32 + "languages": { 33 + "type": "array", 34 + "items": { 35 + "type": "ref", 36 + "ref": "#language" 37 + } 38 + }, 39 + "totalSize": { 40 + "type": "integer", 41 + "description": "Total size of all analyzed files in bytes" 42 + }, 43 + "totalFiles": { 44 + "type": "integer", 45 + "description": "Total number of files analyzed" 46 + } 47 + } 48 + } 49 + }, 50 + "errors": [ 51 + { 52 + "name": "RepoNotFound", 53 + "description": "Repository not found or access denied" 54 + }, 55 + { 56 + "name": "RefNotFound", 57 + "description": "Git reference not found" 58 + }, 59 + { 60 + "name": "InvalidRequest", 61 + "description": "Invalid request parameters" 62 + } 63 + ] 64 + }, 65 + "language": { 66 + "type": "object", 67 + "required": ["name", "size", "percentage"], 68 + "properties": { 69 + "name": { 70 + "type": "string", 71 + "description": "Programming language name" 72 + }, 73 + "size": { 74 + "type": "integer", 75 + "description": "Total size of files in this language (bytes)" 76 + }, 77 + "percentage": { 78 + "type": "integer", 79 + "description": "Percentage of total codebase (0-100)" 80 + }, 81 + "fileCount": { 82 + "type": "integer", 83 + "description": "Number of files in this language" 84 + }, 85 + "color": { 86 + "type": "string", 87 + "description": "Hex color code for this language" 88 + }, 89 + "extensions": { 90 + "type": "array", 91 + "items": { 92 + "type": "string" 93 + }, 94 + "description": "File extensions associated with this language" 95 + } 96 + } 97 + } 98 + } 99 + }
+60
lexicons/repo/log.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.log", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path to filter commits by", 22 + "default": "" 23 + }, 24 + "limit": { 25 + "type": "integer", 26 + "description": "Maximum number of commits to return", 27 + "minimum": 1, 28 + "maximum": 100, 29 + "default": 50 30 + }, 31 + "cursor": { 32 + "type": "string", 33 + "description": "Pagination cursor (commit SHA)" 34 + } 35 + } 36 + }, 37 + "output": { 38 + "encoding": "*/*" 39 + }, 40 + "errors": [ 41 + { 42 + "name": "RepoNotFound", 43 + "description": "Repository not found or access denied" 44 + }, 45 + { 46 + "name": "RefNotFound", 47 + "description": "Git reference not found" 48 + }, 49 + { 50 + "name": "PathNotFound", 51 + "description": "Path not found in repository" 52 + }, 53 + { 54 + "name": "InvalidRequest", 55 + "description": "Invalid request parameters" 56 + } 57 + ] 58 + } 59 + } 60 + }
+43
lexicons/repo/tags.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tags", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "description": "Maximum number of tags to return", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 50 21 + }, 22 + "cursor": { 23 + "type": "string", 24 + "description": "Pagination cursor" 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "*/*" 30 + }, 31 + "errors": [ 32 + { 33 + "name": "RepoNotFound", 34 + "description": "Repository not found or access denied" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+30
api/tangled/tangledowner.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.owner 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + OwnerNSID = "sh.tangled.owner" 15 + ) 16 + 17 + // Owner_Output is the output of a sh.tangled.owner call. 18 + type Owner_Output struct { 19 + Owner string `json:"owner" cborgen:"owner"` 20 + } 21 + 22 + // Owner calls the XRPC method "sh.tangled.owner". 23 + func Owner(ctx context.Context, c util.LexClient) (*Owner_Output, error) { 24 + var out Owner_Output 25 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.owner", nil, nil, &out); err != nil { 26 + return nil, err 27 + } 28 + 29 + return &out, nil 30 + }
+31
lexicons/owner.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.owner", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the owner of a service", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "owner" 14 + ], 15 + "properties": { 16 + "owner": { 17 + "type": "string", 18 + "format": "did" 19 + } 20 + } 21 + } 22 + }, 23 + "errors": [ 24 + { 25 + "name": "OwnerNotFound", 26 + "description": "Owner is not set for this service" 27 + } 28 + ] 29 + } 30 + } 31 + }
-12
appview/pages/templates/knots/fragments/bannerRequiresUpgrade.html
··· 1 - {{ define "knots/fragments/bannerRequiresUpgrade" }} 2 - <div class="px-6 py-2"> 3 - The following knots that you administer will have to be upgraded to be compatible with the latest version of Tangled: 4 - <ul class="list-disc mx-12 my-2"> 5 - {{range $i, $r := .Registrations}} 6 - <li>{{ $r.Domain }}</li> 7 - {{ end }} 8 - </ul> 9 - Repositories hosted on these knots will not be accessible until upgraded. 10 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/migrations/knot-1.8.0.md">Click to read the upgrade guide</a>. 11 - </div> 12 - {{ end }}
+30
api/tangled/knotversion.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot.version 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + KnotVersionNSID = "sh.tangled.knot.version" 15 + ) 16 + 17 + // KnotVersion_Output is the output of a sh.tangled.knot.version call. 18 + type KnotVersion_Output struct { 19 + Version string `json:"version" cborgen:"version"` 20 + } 21 + 22 + // KnotVersion calls the XRPC method "sh.tangled.knot.version". 23 + func KnotVersion(ctx context.Context, c util.LexClient) (*KnotVersion_Output, error) { 24 + var out KnotVersion_Output 25 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.version", nil, nil, &out); err != nil { 26 + return nil, err 27 + } 28 + 29 + return &out, nil 30 + }
+25
lexicons/knot/version.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.version", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the version of a knot", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "version" 14 + ], 15 + "properties": { 16 + "version": { 17 + "type": "string" 18 + } 19 + } 20 + } 21 + }, 22 + "errors": [] 23 + } 24 + } 25 + }
+6 -1
appview/pages/templates/spindles/fragments/spindleListing.html
··· 30 30 {{ define "spindleRightSide" }} 31 31 <div id="right-side" class="flex gap-2"> 32 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 - {{ if .Verified }} 33 + 34 + {{ if .NeedsUpgrade }} 35 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> {{ i "shield-alert" "w-4 h-4" }} needs upgrade </span> 36 + {{ block "spindleRetryButton" . }} {{ end }} 37 + {{ else if .Verified }} 34 38 <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 35 39 {{ template "spindles/fragments/addMemberModal" . }} 36 40 {{ else }} 37 41 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 38 42 {{ block "spindleRetryButton" . }} {{ end }} 39 43 {{ end }} 44 + 40 45 {{ block "spindleDeleteButton" . }} {{ end }} 41 46 </div> 42 47 {{ end }}
+37 -45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 2 + <div id="comment-body-{{.Comment.Id}}" class="pt-2"> 3 + <textarea 4 + id="edit-textarea-{{ .Comment.Id }}" 5 + name="body" 6 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 7 + rows="5" 8 + autofocus>{{ .Comment.Body }}</textarea> 7 9 8 - <!-- show user "hats" --> 9 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 - {{ if $isIssueAuthor }} 11 - <span class="before:content-['·']"></span> 12 - author 13 - {{ end }} 14 - 15 - <span class="before:content-['·']"></span> 16 - <a 17 - href="#{{ .CommentId }}" 18 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 19 - id="{{ .CommentId }}"> 20 - {{ template "repo/fragments/time" .Created }} 21 - </a> 22 - 23 - <button 24 - class="btn px-2 py-1 flex items-center gap-2 text-sm group" 25 - hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 26 - hx-include="#edit-textarea-{{ .CommentId }}" 27 - hx-target="#comment-container-{{ .CommentId }}" 28 - hx-swap="outerHTML"> 29 - {{ i "check" "w-4 h-4" }} 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </button> 32 - <button 33 - class="btn px-2 py-1 flex items-center gap-2 text-sm" 34 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 35 - hx-target="#comment-container-{{ .CommentId }}" 36 - hx-swap="outerHTML"> 37 - {{ i "x" "w-4 h-4" }} 38 - </button> 39 - <span id="comment-{{.CommentId}}-status"></span> 40 - </div> 10 + {{ template "editActions" $ }} 11 + </div> 12 + {{ end }} 41 13 42 - <div> 43 - <textarea 44 - id="edit-textarea-{{ .CommentId }}" 45 - name="body" 46 - class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea> 47 - </div> 14 + {{ define "editActions" }} 15 + <div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2"> 16 + {{ template "cancel" . }} 17 + {{ template "save" . }} 48 18 </div> 49 - {{ end }} 50 19 {{ end }} 51 20 21 + {{ define "save" }} 22 + <button 23 + class="btn-create py-0 flex gap-1 items-center group text-sm" 24 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 25 + hx-include="#edit-textarea-{{ .Comment.Id }}" 26 + hx-target="#comment-body-{{ .Comment.Id }}" 27 + hx-swap="outerHTML"> 28 + {{ i "check" "size-4" }} 29 + save 30 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 + </button> 32 + {{ end }} 33 + 34 + {{ define "cancel" }} 35 + <button 36 + class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group" 37 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 38 + hx-target="#comment-body-{{ .Comment.Id }}" 39 + hx-swap="outerHTML"> 40 + {{ i "x" "size-4" }} 41 + cancel 42 + </button> 43 + {{ end }}
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 1 - {{ define "repo/issues/fragments/issueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ template "user/fragments/picHandleLink" .OwnerDid }} 6 - 7 - <!-- show user "hats" --> 8 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 9 - {{ if $isIssueAuthor }} 10 - <span class="before:content-['·']"></span> 11 - author 12 - {{ end }} 13 - 14 - <span class="before:content-['·']"></span> 15 - <a 16 - href="#{{ .CommentId }}" 17 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 18 - id="{{ .CommentId }}"> 19 - {{ if .Deleted }} 20 - deleted {{ template "repo/fragments/time" .Deleted }} 21 - {{ else if .Edited }} 22 - edited {{ template "repo/fragments/time" .Edited }} 23 - {{ else }} 24 - {{ template "repo/fragments/time" .Created }} 25 - {{ end }} 26 - </a> 27 - 28 - {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 29 - {{ if and $isCommentOwner (not .Deleted) }} 30 - <button 31 - class="btn px-2 py-1 text-sm" 32 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 33 - hx-swap="outerHTML" 34 - hx-target="#comment-container-{{.CommentId}}" 35 - > 36 - {{ i "pencil" "w-4 h-4" }} 37 - </button> 38 - <button 39 - class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 40 - hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 41 - hx-confirm="Are you sure you want to delete your comment?" 42 - hx-swap="outerHTML" 43 - hx-target="#comment-container-{{.CommentId}}" 44 - > 45 - {{ i "trash-2" "w-4 h-4" }} 46 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 47 - </button> 48 - {{ end }} 49 - 50 - </div> 51 - {{ if not .Deleted }} 52 - <div class="prose dark:prose-invert"> 53 - {{ .Body | markdown }} 54 - </div> 55 - {{ end }} 56 - </div> 57 - {{ end }} 58 - {{ end }}
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
··· 1 + {{ define "repo/issues/fragments/issueCommentBody" }} 2 + <div id="comment-body-{{.Comment.Id}}"> 3 + {{ if not .Comment.Deleted }} 4 + <div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div> 5 + {{ else }} 6 + <div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div> 7 + {{ end }} 8 + </div> 9 + {{ end }}
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
··· 1 + {{ define "repo/issues/fragments/putIssue" }} 2 + <!-- this form is used for new and edit, .Issue is passed when editing --> 3 + <form 4 + {{ if eq .Action "edit" }} 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 6 + {{ else }} 7 + hx-post="/{{ .RepoInfo.FullName }}/issues/new" 8 + {{ end }} 9 + hx-swap="none" 10 + hx-indicator="#spinner"> 11 + <div class="flex flex-col gap-2"> 12 + <div> 13 + <label for="title">title</label> 14 + <input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" /> 15 + </div> 16 + <div> 17 + <label for="body">body</label> 18 + <textarea 19 + name="body" 20 + id="body" 21 + rows="6" 22 + class="w-full resize-y" 23 + placeholder="Describe your issue. Markdown is supported." 24 + >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea> 25 + </div> 26 + <div class="flex justify-between"> 27 + <div id="issues" class="error"></div> 28 + <div class="flex gap-2 items-center"> 29 + <a 30 + class="btn flex items-center gap-2 no-underline hover:no-underline" 31 + type="button" 32 + {{ if .Issue }} 33 + href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}" 34 + {{ else }} 35 + href="/{{ .RepoInfo.FullName }}/issues" 36 + {{ end }} 37 + > 38 + {{ i "x" "w-4 h-4" }} 39 + cancel 40 + </a> 41 + <button type="submit" class="btn-create flex items-center gap-2"> 42 + {{ if eq .Action "edit" }} 43 + {{ i "pencil" "w-4 h-4" }} 44 + {{ .Action }} issue 45 + {{ else }} 46 + {{ i "circle-plus" "w-4 h-4" }} 47 + {{ .Action }} issue 48 + {{ end }} 49 + <span id="spinner" class="group"> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 51 + </span> 52 + </button> 53 + </div> 54 + </div> 55 + </div> 56 + </form> 57 + {{ end }}
+1 -33
appview/pages/templates/repo/issues/new.html
··· 1 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "repoContent" }} 4 - <form 5 - hx-post="/{{ .RepoInfo.FullName }}/issues/new" 6 - class="space-y-6" 7 - hx-swap="none" 8 - hx-indicator="#spinner" 9 - > 10 - <div class="flex flex-col gap-4"> 11 - <div> 12 - <label for="title">title</label> 13 - <input type="text" name="title" id="title" class="w-full" /> 14 - </div> 15 - <div> 16 - <label for="body">body</label> 17 - <textarea 18 - name="body" 19 - id="body" 20 - rows="6" 21 - class="w-full resize-y" 22 - placeholder="Describe your issue. Markdown is supported." 23 - ></textarea> 24 - </div> 25 - <div> 26 - <button type="submit" class="btn-create flex items-center gap-2"> 27 - {{ i "circle-plus" "w-4 h-4" }} 28 - create issue 29 - <span id="spinner" class="group"> 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </span> 32 - </button> 33 - </div> 34 - </div> 35 - <div id="issues" class="error"></div> 36 - </form> 4 + {{ template "repo/issues/fragments/putIssue" . }} 37 5 {{ end }}
+5
xrpc/errors/errors.go
··· 61 61 WithMessage("failed to access repository"), 62 62 ) 63 63 64 + var RefNotFoundError = NewXrpcError( 65 + WithTag("RefNotFound"), 66 + WithMessage("failed to access ref"), 67 + ) 68 + 64 69 var AuthError = func(err error) XrpcError { 65 70 return NewXrpcError( 66 71 WithTag("Auth"),
+15
flake.lock
··· 1 1 { 2 2 "nodes": { 3 + "flake-compat": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1751685974, 7 + "narHash": "sha256-NKw96t+BgHIYzHUjkTK95FqYRVKB8DHpVhefWSz/kTw=", 8 + "rev": "549f2762aebeff29a2e5ece7a7dc0f955281a1d1", 9 + "type": "tarball", 10 + "url": "https://git.lix.systems/api/v1/repos/lix-project/flake-compat/archive/549f2762aebeff29a2e5ece7a7dc0f955281a1d1.tar.gz?rev=549f2762aebeff29a2e5ece7a7dc0f955281a1d1" 11 + }, 12 + "original": { 13 + "type": "tarball", 14 + "url": "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz" 15 + } 16 + }, 3 17 "flake-utils": { 4 18 "inputs": { 5 19 "systems": "systems" ··· 136 150 }, 137 151 "root": { 138 152 "inputs": { 153 + "flake-compat": "flake-compat", 139 154 "gomod2nix": "gomod2nix", 140 155 "htmx-src": "htmx-src", 141 156 "htmx-ws-src": "htmx-ws-src",
+44
contrib/Tiltfile
··· 1 + common_env = { 2 + "TANGLED_VM_SPINDLE_OWNER": os.getenv("TANGLED_VM_SPINDLE_OWNER", default=""), 3 + "TANGLED_VM_KNOT_OWNER": os.getenv("TANGLED_VM_KNOT_OWNER", default=""), 4 + "TANGLED_DB_PATH": os.getenv("TANGLED_DB_PATH", default="dev.db"), 5 + "TANGLED_DEV": os.getenv("TANGLED_DEV", default="true"), 6 + } 7 + 8 + nix_globs = ["nix/**", "flake.nix", "flake.lock"] 9 + 10 + local_resource( 11 + name="appview", 12 + serve_cmd="nix run .#watch-appview", 13 + serve_dir="..", 14 + deps=nix_globs, 15 + env=common_env, 16 + allow_parallel=True, 17 + ) 18 + 19 + local_resource( 20 + name="tailwind", 21 + serve_cmd="nix run .#watch-tailwind", 22 + serve_dir="..", 23 + deps=nix_globs, 24 + env=common_env, 25 + allow_parallel=True, 26 + ) 27 + 28 + local_resource( 29 + name="redis", 30 + serve_cmd="redis-server", 31 + serve_dir="..", 32 + deps=nix_globs, 33 + env=common_env, 34 + allow_parallel=True, 35 + ) 36 + 37 + local_resource( 38 + name="vm", 39 + serve_cmd="nix run --impure .#vm", 40 + serve_dir="..", 41 + deps=nix_globs, 42 + env=common_env, 43 + allow_parallel=True, 44 + )
+90
appview/pages/templates/fragments/multiline-select.html
··· 1 + {{ define "fragments/multiline-select" }} 2 + <script> 3 + function highlight(scroll = false) { 4 + document.querySelectorAll(".hl").forEach(el => { 5 + el.classList.remove("hl"); 6 + }); 7 + 8 + const hash = window.location.hash; 9 + if (!hash || !hash.startsWith("#L")) { 10 + return; 11 + } 12 + 13 + const rangeStr = hash.substring(2); 14 + const parts = rangeStr.split("-"); 15 + let startLine, endLine; 16 + 17 + if (parts.length === 2) { 18 + startLine = parseInt(parts[0], 10); 19 + endLine = parseInt(parts[1], 10); 20 + } else { 21 + startLine = parseInt(parts[0], 10); 22 + endLine = startLine; 23 + } 24 + 25 + if (isNaN(startLine) || isNaN(endLine)) { 26 + console.log("nan"); 27 + console.log(startLine); 28 + console.log(endLine); 29 + return; 30 + } 31 + 32 + let target = null; 33 + 34 + for (let i = startLine; i<= endLine; i++) { 35 + const idEl = document.getElementById(`L${i}`); 36 + if (idEl) { 37 + const el = idEl.closest(".line"); 38 + if (el) { 39 + el.classList.add("hl"); 40 + target = el; 41 + } 42 + } 43 + } 44 + 45 + if (scroll && target) { 46 + target.scrollIntoView({ 47 + behavior: "smooth", 48 + block: "center", 49 + }); 50 + } 51 + } 52 + 53 + document.addEventListener("DOMContentLoaded", () => { 54 + console.log("DOMContentLoaded"); 55 + highlight(true); 56 + }); 57 + window.addEventListener("hashchange", () => { 58 + console.log("hashchange"); 59 + highlight(); 60 + }); 61 + window.addEventListener("popstate", () => { 62 + console.log("popstate"); 63 + highlight(); 64 + }); 65 + 66 + const lineNumbers = document.querySelectorAll('a[href^="#L"'); 67 + let startLine = null; 68 + 69 + lineNumbers.forEach(el => { 70 + el.addEventListener("click", (event) => { 71 + event.preventDefault(); 72 + const currentLine = parseInt(el.href.split("#L")[1]); 73 + 74 + if (event.shiftKey && startLine !== null) { 75 + const endLine = currentLine; 76 + const min = Math.min(startLine, endLine); 77 + const max = Math.max(startLine, endLine); 78 + const newHash = `#L${min}-${max}`; 79 + history.pushState(null, '', newHash); 80 + } else { 81 + const newHash = `#L${currentLine}`; 82 + history.pushState(null, '', newHash); 83 + startLine = currentLine; 84 + } 85 + 86 + highlight(); 87 + }); 88 + }); 89 + </script> 90 + {{ end }}
+56
appview/pages/templates/fragments/dolly/logo.html
··· 1 + {{ define "fragments/dolly/logo" }} 2 + <svg 3 + version="1.1" 4 + id="svg1" 5 + class="{{.}}" 6 + width="25" 7 + height="25" 8 + viewBox="0 0 25 25" 9 + sodipodi:docname="tangled_dolly_face_only.png" 10 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 11 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 12 + xmlns:xlink="http://www.w3.org/1999/xlink" 13 + xmlns="http://www.w3.org/2000/svg" 14 + xmlns:svg="http://www.w3.org/2000/svg"> 15 + <title>Dolly</title> 16 + <defs 17 + id="defs1" /> 18 + <sodipodi:namedview 19 + id="namedview1" 20 + pagecolor="#ffffff" 21 + bordercolor="#000000" 22 + borderopacity="0.25" 23 + inkscape:showpageshadow="2" 24 + inkscape:pageopacity="0.0" 25 + inkscape:pagecheckerboard="true" 26 + inkscape:deskcolor="#d5d5d5"> 27 + <inkscape:page 28 + x="0" 29 + y="0" 30 + width="25" 31 + height="25" 32 + id="page2" 33 + margin="0" 34 + bleed="0" /> 35 + </sodipodi:namedview> 36 + <g 37 + inkscape:groupmode="layer" 38 + inkscape:label="Image" 39 + id="g1"> 40 + <image 41 + width="252.48" 42 + height="248.96001" 43 + preserveAspectRatio="none" 44 + xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9&#10;kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI&#10;foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7&#10;vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0&#10;M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp&#10;rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T&#10;IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0&#10;AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI&#10;WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk&#10;IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39&#10;NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz&#10;3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS&#10;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&#10;U2QU7On6+9OkT6L5/hwwWxRFZ13erbQJGVRIknSFpqampn7961/vuf/++/ft27fvjsnJyVvKsrxp&#10;YWFh79LS0r52u33L2NjY9vHx8ZGhoSGSyNBVWgYuAB8Dh0h50wfACRJAnCc7J+bsmZDWj1c5SZKu&#10;0muvvVa0Wq3ij3/84/D09PT2bdu23XPnnXd+7Y477nhg7969e3fv3r1lbGxsBBiqqmq4LMtxYKrd&#10;bt/QarXGi6Jwq3eUZI/EMslILJKdEvMk63AceIfsljhMMhKz9d9f4m4bad05zkKSpKv06KOPVkA1&#10;MjKy9KMf/ejM+Pj4+bIs36iqarjT6QzNzMwMTU9PD09PT08uLCzsHB0dvWPbtm1f3bVr1wOTk5P7&#10;hoeHd5CSqWbpXtME3qLu1+j5vp97Nio+2wfRvJpAYh64RHZJNFuuT5JsxCf199Ok/Gmp/nMGEtIG&#10;0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A&#10;rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/&#10;//6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z&#10;k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf&#10;f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF&#10;HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK&#10;KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj&#10;4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC&#10;kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC&#10;/wcO9A7eMaXQEQAAAABJRU5ErkJggg==&#10;" 45 + id="image1" 46 + x="-233.6257" 47 + y="10.383364" 48 + style="display:none" /> 49 + <path 50 + fill="currentColor" 51 + style="stroke-width:0.111183" 52 + d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z" 53 + id="path4" /> 54 + </g> 55 + </svg> 56 + {{ end }}
+57
appview/pages/templates/fragments/dolly/silhouette.html
··· 1 + {{ define "fragments/dolly/silhouette" }} 2 + <svg 3 + version="1.1" 4 + id="svg1" 5 + width="32" 6 + height="32" 7 + viewBox="0 0 25 25" 8 + sodipodi:docname="tangled_dolly_silhouette.png" 9 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 10 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 11 + xmlns="http://www.w3.org/2000/svg" 12 + xmlns:svg="http://www.w3.org/2000/svg"> 13 + <style> 14 + .dolly { 15 + color: #000000; 16 + } 17 + 18 + @media (prefers-color-scheme: dark) { 19 + .dolly { 20 + color: #ffffff; 21 + } 22 + } 23 + </style> 24 + <title>Dolly</title> 25 + <defs 26 + id="defs1" /> 27 + <sodipodi:namedview 28 + id="namedview1" 29 + pagecolor="#ffffff" 30 + bordercolor="#000000" 31 + borderopacity="0.25" 32 + inkscape:showpageshadow="2" 33 + inkscape:pageopacity="0.0" 34 + inkscape:pagecheckerboard="true" 35 + inkscape:deskcolor="#d1d1d1"> 36 + <inkscape:page 37 + x="0" 38 + y="0" 39 + width="25" 40 + height="25" 41 + id="page2" 42 + margin="0" 43 + bleed="0" /> 44 + </sodipodi:namedview> 45 + <g 46 + inkscape:groupmode="layer" 47 + inkscape:label="Image" 48 + id="g1"> 49 + <path 50 + class="dolly" 51 + fill="currentColor" 52 + style="stroke-width:1.12248" 53 + d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 54 + id="path1" /> 55 + </g> 56 + </svg> 57 + {{ end }}
+9
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 7 + </span> 8 + <span> 9 + {{ end }}
+1 -1
appview/pages/templates/repo/blob.html
··· 4 4 {{ template "repo/fragments/meta" . }} 5 5 6 6 {{ $title := printf "%s at %s &middot; %s" .Path .Ref .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 7 + {{ $url := printf "https://tangled.org/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 8 8 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10
+2 -2
appview/pages/templates/repo/pipelines/pipelines.html
··· 2 2 3 3 {{ define "extrameta" }} 4 4 {{ $title := "pipelines"}} 5 - {{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }} 6 6 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 7 7 {{ end }} 8 8 ··· 60 60 <span class="inline-flex gap-2 items-center"> 61 61 <span class="font-bold">{{ $target }}</span> 62 62 {{ i "arrow-left" "size-4" }} 63 - {{ .Trigger.PRSourceBranch }} 63 + {{ .Trigger.PRSourceBranch }} 64 64 <span class="text-sm font-mono"> 65 65 @ 66 66 <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a>
+1 -1
appview/pages/templates/repo/pipelines/workflow.html
··· 2 2 3 3 {{ define "extrameta" }} 4 4 {{ $title := "pipelines"}} 5 - {{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }} 6 6 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 7 7 {{ end }} 8 8
+10 -10
appview/pages/templates/repo/tags.html
··· 4 4 5 5 {{ define "extrameta" }} 6 6 {{ $title := printf "tags &middot; %s" .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.sh/%s/tags" .RepoInfo.FullName }} 8 - 7 + {{ $url := printf "https://tangled.org/%s/tags" .RepoInfo.FullName }} 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 11 ··· 26 26 27 27 <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 text-sm"> 28 28 {{ if .Tag }} 29 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 29 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 30 30 class="no-underline hover:underline text-gray-500 dark:text-gray-400"> 31 31 {{ slice .Tag.Target.String 0 8 }} 32 32 </a> ··· 48 48 </a> 49 49 <div class="flex flex-grow flex-col text-gray-500 dark:text-gray-400 text-sm"> 50 50 {{ if .Tag }} 51 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 51 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 52 52 class="no-underline hover:underline text-gray-500 dark:text-gray-400 flex items-center gap-2"> 53 53 {{ i "git-commit-horizontal" "w-4 h-4" }} 54 54 {{ slice .Tag.Target.String 0 8 }} ··· 132 132 hx-target="this" 133 133 class="flex items-center gap-2 px-2"> 134 134 <div class="flex-grow"> 135 - <input type="file" 136 - name="artifact" 135 + <input type="file" 136 + name="artifact" 137 137 required 138 138 class="block py-2 px-0 w-full border-none 139 139 text-black dark:text-white ··· 148 148 </input> 149 149 </div> 150 150 <div class="flex justify-end"> 151 - <button 152 - type="submit" 153 - class="btn gap-2" 151 + <button 152 + type="submit" 153 + class="btn gap-2" 154 154 id="upload-btn-{{$unique}}" 155 155 title="Upload artifact"> 156 156 {{ i "upload" "w-4 h-4" }} 157 - <span class="hidden md:inline">upload</span> 157 + <span class="hidden md:inline">upload</span> 158 158 </button> 159 159 </div> 160 160 </form>
+1 -1
appview/pages/templates/spindles/index.html
··· 5 5 <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 6 <span class="flex items-center gap-1"> 7 7 {{ i "book" "w-3 h-3" }} 8 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a> 8 + <a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">docs</a> 9 9 </span> 10 10 </div> 11 11
+1 -1
appview/pages/templates/strings/dashboard.html
··· 3 3 {{ define "extrameta" }} 4 4 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 5 <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 6 + <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 8 {{ end }} 9 9
+1 -1
appview/pages/templates/strings/string.html
··· 4 4 {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 5 <meta property="og:title" content="{{ .String.Filename }} · by {{ $ownerId }}" /> 6 6 <meta property="og:type" content="object" /> 7 - <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 7 + <meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 8 <meta property="og:description" content="{{ .String.Description }}" /> 9 9 {{ end }} 10 10
+1 -2
appview/pages/templates/timeline/fragments/hero.html
··· 22 22 </div> 23 23 24 24 <figure class="w-full hidden md:block md:w-auto"> 25 - <a href="https://tangled.sh/@tangled.sh/core" class="block"> 25 + <a href="https://tangled.org/@tangled.org/core" class="block"> 26 26 <img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" /> 27 27 </a> 28 28 <figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center"> ··· 31 31 </figure> 32 32 </div> 33 33 {{ end }} 34 -
+4 -4
readme.md
··· 1 1 # tangled 2 2 3 3 Hello Tanglers! This is the codebase for 4 - [Tangled](https://tangled.sh)&mdash;a code collaboration platform built 4 + [Tangled](https://tangled.org)&mdash;a code collaboration platform built 5 5 on the [AT Protocol](https://atproto.com). 6 6 7 - Read the introduction to Tangled [here](https://blog.tangled.sh/intro). Join the 8 - [Discord](https://chat.tangled.sh) or IRC at [#tangled on 7 + Read the introduction to Tangled [here](https://blog.tangled.org/intro). Join the 8 + [Discord](https://chat.tangled.org) or IRC at [#tangled on 9 9 libera.chat](https://web.libera.chat/#tangled). 10 10 11 11 ## docs ··· 17 17 ## security 18 18 19 19 If you've identified a security issue in Tangled, please email 20 - [security@tangled.sh](mailto:security@tangled.sh) with details! 20 + [security@tangled.org](mailto:security@tangled.org) with details!
+1 -1
appview/cache/session/store.go
··· 6 6 "fmt" 7 7 "time" 8 8 9 - "tangled.sh/tangled.sh/core/appview/cache" 9 + "tangled.org/core/appview/cache" 10 10 ) 11 11 12 12 type OAuthSession struct {
+1 -1
appview/pipelines/router.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 7 + "tangled.org/core/appview/middleware" 8 8 ) 9 9 10 10 func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
+4 -4
appview/serververify/verify.go
··· 6 6 "fmt" 7 7 8 8 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 - "tangled.sh/tangled.sh/core/api/tangled" 10 - "tangled.sh/tangled.sh/core/appview/db" 11 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 12 - "tangled.sh/tangled.sh/core/rbac" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/rbac" 13 13 ) 14 14 15 15 var (
+1 -1
cmd/combinediff/main.go
··· 5 5 "os" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 8 + "tangled.org/core/patchutil" 9 9 ) 10 10 11 11 func main() {
+1 -1
cmd/interdiff/main.go
··· 5 5 "os" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 8 + "tangled.org/core/patchutil" 9 9 ) 10 10 11 11 func main() {
+2 -2
docs/knot-hosting.md
··· 19 19 First, clone this repository: 20 20 21 21 ``` 22 - git clone https://tangled.sh/@tangled.sh/core 22 + git clone https://tangled.org/@tangled.org/core 23 23 ``` 24 24 25 25 Then, build the `knot` CLI. This is the knot administration and operation tool. ··· 130 130 131 131 You should now have a running knot server! You can finalize 132 132 your registration by hitting the `verify` button on the 133 - [/knots](https://tangled.sh/knots) page. This simply creates 133 + [/knots](https://tangled.org/knots) page. This simply creates 134 134 a record on your PDS to announce the existence of the knot. 135 135 136 136 ### custom paths
+4 -5
docs/migrations.md
··· 14 14 For knots: 15 15 16 16 - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.sh/knots) and 17 + - Head to the [knot dashboard](https://tangled.org/knots) and 18 18 hit the "retry" button to verify your knot 19 19 20 20 For spindles: 21 21 22 22 - Upgrade to latest tag (v1.9.0 or above) 23 23 - Head to the [spindle 24 - dashboard](https://tangled.sh/spindles) and hit the 24 + dashboard](https://tangled.org/spindles) and hit the 25 25 "retry" button to verify your spindle 26 26 27 27 ## Upgrading from v1.7.x ··· 38 38 environment variable entirely 39 39 - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 40 your DID. You can find your DID in the 41 - [settings](https://tangled.sh/settings) page. 41 + [settings](https://tangled.org/settings) page. 42 42 - Restart your knot once you have replaced the environment 43 43 variable 44 - - Head to the [knot dashboard](https://tangled.sh/knots) and 44 + - Head to the [knot dashboard](https://tangled.org/knots) and 45 45 hit the "retry" button to verify your knot. This simply 46 46 writes a `sh.tangled.knot` record to your PDS. 47 47 ··· 57 57 }; 58 58 }; 59 59 ``` 60 -
+1 -1
keyfetch/keyfetch.go
··· 10 10 "strings" 11 11 12 12 "github.com/urfave/cli/v3" 13 - "tangled.sh/tangled.sh/core/log" 13 + "tangled.org/core/log" 14 14 ) 15 15 16 16 func Command() *cli.Command {
+1 -1
knotserver/db/events.go
··· 4 4 "fmt" 5 5 "time" 6 6 7 - "tangled.sh/tangled.sh/core/notifier" 7 + "tangled.org/core/notifier" 8 8 ) 9 9 10 10 type Event struct {
+2 -2
knotserver/git/diff.go
··· 12 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 13 "github.com/go-git/go-git/v5/plumbing" 14 14 "github.com/go-git/go-git/v5/plumbing/object" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 - "tangled.sh/tangled.sh/core/types" 15 + "tangled.org/core/patchutil" 16 + "tangled.org/core/types" 17 17 ) 18 18 19 19 func (g *GitRepo) Diff() (*types.NiceDiff, error) {
+1 -1
knotserver/git/post_receive.go
··· 9 9 "strings" 10 10 "time" 11 11 12 - "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.org/core/api/tangled" 13 13 14 14 "github.com/go-git/go-git/v5/plumbing" 15 15 )
+1 -1
knotserver/git/tree.go
··· 8 8 "time" 9 9 10 10 "github.com/go-git/go-git/v5/plumbing/object" 11 - "tangled.sh/tangled.sh/core/types" 11 + "tangled.org/core/types" 12 12 ) 13 13 14 14 func (g *GitRepo) FileTree(ctx context.Context, path string) ([]types.NiceTree, error) {
+3 -3
knotserver/xrpc/delete_repo.go
··· 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 "github.com/bluesky-social/indigo/xrpc" 13 13 securejoin "github.com/cyphar/filepath-securejoin" 14 - "tangled.sh/tangled.sh/core/api/tangled" 15 - "tangled.sh/tangled.sh/core/rbac" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/rbac" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 17 ) 18 18 19 19 func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/list_keys.go
··· 4 4 "net/http" 5 5 "strconv" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 7 + "tangled.org/core/api/tangled" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 9 ) 10 10 11 11 func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/owner.go
··· 3 3 import ( 4 4 "net/http" 5 5 6 - "tangled.sh/tangled.sh/core/api/tangled" 7 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 6 + "tangled.org/core/api/tangled" 7 + xrpcerr "tangled.org/core/xrpc/errors" 8 8 ) 9 9 10 10 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_branch.go
··· 5 5 "net/url" 6 6 "time" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/knotserver/git" 10 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/knotserver/git" 10 + xrpcerr "tangled.org/core/xrpc/errors" 11 11 ) 12 12 13 13 func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_compare.go
··· 4 4 "fmt" 5 5 "net/http" 6 6 7 - "tangled.sh/tangled.sh/core/knotserver/git" 8 - "tangled.sh/tangled.sh/core/types" 9 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 10 ) 11 11 12 12 func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_diff.go
··· 3 3 import ( 4 4 "net/http" 5 5 6 - "tangled.sh/tangled.sh/core/knotserver/git" 7 - "tangled.sh/tangled.sh/core/types" 8 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 6 + "tangled.org/core/knotserver/git" 7 + "tangled.org/core/types" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 9 ) 10 10 11 11 func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_languages.go
··· 6 6 "net/http" 7 7 "time" 8 8 9 - "tangled.sh/tangled.sh/core/api/tangled" 10 - "tangled.sh/tangled.sh/core/knotserver/git" 11 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/knotserver/git" 11 + xrpcerr "tangled.org/core/xrpc/errors" 12 12 ) 13 13 14 14 func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_tags.go
··· 7 7 "github.com/go-git/go-git/v5/plumbing" 8 8 "github.com/go-git/go-git/v5/plumbing/object" 9 9 10 - "tangled.sh/tangled.sh/core/knotserver/git" 11 - "tangled.sh/tangled.sh/core/types" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + "tangled.org/core/knotserver/git" 11 + "tangled.org/core/types" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 13 ) 14 14 15 15 func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/version.go
··· 5 5 "net/http" 6 6 "runtime/debug" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.org/core/api/tangled" 9 9 ) 10 10 11 11 // version is set during build time. ··· 24 24 var modified bool 25 25 26 26 for _, mod := range info.Deps { 27 - if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" { 27 + if mod.Path == "tangled.org/tangled.org/knotserver/xrpc" { 28 28 modVer = mod.Version 29 29 break 30 30 }
+1 -1
lexicon-build-config.json
··· 3 3 "package": "tangled", 4 4 "prefix": "sh.tangled", 5 5 "outdir": "api/tangled", 6 - "import": "tangled.sh/tangled.sh/core/api/tangled", 6 + "import": "tangled.org/core/api/tangled", 7 7 "gen-server": true 8 8 } 9 9 ]
+1 -1
patchutil/patchutil.go
··· 10 10 "strings" 11 11 12 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 - "tangled.sh/tangled.sh/core/types" 13 + "tangled.org/core/types" 14 14 ) 15 15 16 16 func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) {
+4 -4
spindle/db/events.go
··· 5 5 "fmt" 6 6 "time" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/notifier" 10 - "tangled.sh/tangled.sh/core/spindle/models" 11 - "tangled.sh/tangled.sh/core/tid" 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/notifier" 10 + "tangled.org/core/spindle/models" 11 + "tangled.org/core/tid" 12 12 ) 13 13 14 14 type Event struct {
+5 -5
spindle/engine/engine.go
··· 8 8 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 10 "golang.org/x/sync/errgroup" 11 - "tangled.sh/tangled.sh/core/notifier" 12 - "tangled.sh/tangled.sh/core/spindle/config" 13 - "tangled.sh/tangled.sh/core/spindle/db" 14 - "tangled.sh/tangled.sh/core/spindle/models" 15 - "tangled.sh/tangled.sh/core/spindle/secrets" 11 + "tangled.org/core/notifier" 12 + "tangled.org/core/spindle/config" 13 + "tangled.org/core/spindle/db" 14 + "tangled.org/core/spindle/models" 15 + "tangled.org/core/spindle/secrets" 16 16 ) 17 17 18 18 var (
+2 -2
spindle/engines/nixery/setup_steps.go
··· 5 5 "path" 6 6 "strings" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/workflow" 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/workflow" 10 10 ) 11 11 12 12 func nixConfStep() Step {
+9 -9
spindle/xrpc/xrpc.go
··· 8 8 9 9 "github.com/go-chi/chi/v5" 10 10 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/idresolver" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - "tangled.sh/tangled.sh/core/spindle/config" 15 - "tangled.sh/tangled.sh/core/spindle/db" 16 - "tangled.sh/tangled.sh/core/spindle/models" 17 - "tangled.sh/tangled.sh/core/spindle/secrets" 18 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/idresolver" 13 + "tangled.org/core/rbac" 14 + "tangled.org/core/spindle/config" 15 + "tangled.org/core/spindle/db" 16 + "tangled.org/core/spindle/models" 17 + "tangled.org/core/spindle/secrets" 18 + xrpcerr "tangled.org/core/xrpc/errors" 19 + "tangled.org/core/xrpc/serviceauth" 20 20 ) 21 21 22 22 const ActorDid string = "ActorDid"
+1 -1
workflow/def.go
··· 6 6 "slices" 7 7 "strings" 8 8 9 - "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.org/core/api/tangled" 10 10 11 11 "github.com/go-git/go-git/v5/plumbing" 12 12 "gopkg.in/yaml.v3"
+42
api/tangled/labeldefinition.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.label.definition 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + LabelDefinitionNSID = "sh.tangled.label.definition" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.label.definition", &LabelDefinition{}) 17 + } // 18 + // RECORDTYPE: LabelDefinition 19 + type LabelDefinition struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.label.definition" cborgen:"$type,const=sh.tangled.label.definition"` 21 + // color: The hex value for the background color for the label. Appviews may choose to respect this. 22 + Color *string `json:"color,omitempty" cborgen:"color,omitempty"` 23 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 + // multiple: Whether this label can be repeated for a given entity, eg.: [reviewer:foo, reviewer:bar] 25 + Multiple *bool `json:"multiple,omitempty" cborgen:"multiple,omitempty"` 26 + // name: The display name of this label. 27 + Name string `json:"name" cborgen:"name"` 28 + // scope: The areas of the repo this label may apply to, eg.: sh.tangled.repo.issue. Appviews may choose to respect this. 29 + Scope []string `json:"scope" cborgen:"scope"` 30 + // valueType: The type definition of this label. Appviews may allow sorting for certain types. 31 + ValueType *LabelDefinition_ValueType `json:"valueType" cborgen:"valueType"` 32 + } 33 + 34 + // LabelDefinition_ValueType is a "valueType" in the sh.tangled.label.definition schema. 35 + type LabelDefinition_ValueType struct { 36 + // enum: Closed set of values that this label can take. 37 + Enum []string `json:"enum,omitempty" cborgen:"enum,omitempty"` 38 + // format: An optional constraint that can be applied on string concrete types. 39 + Format string `json:"format" cborgen:"format"` 40 + // type: The concrete type of this label's value. 41 + Type string `json:"type" cborgen:"type"` 42 + }
+89
lexicons/label/definition.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.label.definition", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "valueType", 15 + "scope", 16 + "createdAt" 17 + ], 18 + "properties": { 19 + "name": { 20 + "type": "string", 21 + "description": "The display name of this label.", 22 + "minGraphemes": 1, 23 + "maxGraphemes": 40 24 + }, 25 + "valueType": { 26 + "type": "ref", 27 + "ref": "#valueType", 28 + "description": "The type definition of this label. Appviews may allow sorting for certain types." 29 + }, 30 + "scope": { 31 + "type": "array", 32 + "description": "The areas of the repo this label may apply to, eg.: sh.tangled.repo.issue. Appviews may choose to respect this.", 33 + "items": { 34 + "type": "string", 35 + "format": "nsid" 36 + } 37 + }, 38 + "color": { 39 + "type": "string", 40 + "description": "The hex value for the background color for the label. Appviews may choose to respect this." 41 + }, 42 + "createdAt": { 43 + "type": "string", 44 + "format": "datetime" 45 + }, 46 + "multiple": { 47 + "type": "boolean", 48 + "description": "Whether this label can be repeated for a given entity, eg.: [reviewer:foo, reviewer:bar]" 49 + } 50 + } 51 + } 52 + }, 53 + "valueType": { 54 + "type": "object", 55 + "required": [ 56 + "type", 57 + "format" 58 + ], 59 + "properties": { 60 + "type": { 61 + "type": "string", 62 + "enum": [ 63 + "null", 64 + "boolean", 65 + "integer", 66 + "string" 67 + ], 68 + "description": "The concrete type of this label's value." 69 + }, 70 + "format": { 71 + "type": "string", 72 + "enum": [ 73 + "any", 74 + "did", 75 + "nsid" 76 + ], 77 + "description": "An optional constraint that can be applied on string concrete types." 78 + }, 79 + "enum": { 80 + "type": "array", 81 + "description": "Closed set of values that this label can take.", 82 + "items": { 83 + "type": "string" 84 + } 85 + } 86 + } 87 + } 88 + } 89 + }
+34
api/tangled/labelop.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.label.op 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + LabelOpNSID = "sh.tangled.label.op" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.label.op", &LabelOp{}) 17 + } // 18 + // RECORDTYPE: LabelOp 19 + type LabelOp struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.label.op" cborgen:"$type,const=sh.tangled.label.op"` 21 + Add []*LabelOp_Operand `json:"add" cborgen:"add"` 22 + Delete []*LabelOp_Operand `json:"delete" cborgen:"delete"` 23 + PerformedAt string `json:"performedAt" cborgen:"performedAt"` 24 + // subject: The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op. 25 + Subject string `json:"subject" cborgen:"subject"` 26 + } 27 + 28 + // LabelOp_Operand is a "operand" in the sh.tangled.label.op schema. 29 + type LabelOp_Operand struct { 30 + // key: ATURI to the label definition 31 + Key string `json:"key" cborgen:"key"` 32 + // value: Stringified value of the label. This is first unstringed by appviews and then interpreted as a concrete value. 33 + Value string `json:"value" cborgen:"value"` 34 + }
+2
cmd/gen.go
··· 28 28 tangled.KnotMember{}, 29 29 tangled.LabelDefinition{}, 30 30 tangled.LabelDefinition_ValueType{}, 31 + tangled.LabelOp{}, 32 + tangled.LabelOp_Operand{}, 31 33 tangled.Pipeline{}, 32 34 tangled.Pipeline_CloneOpts{}, 33 35 tangled.Pipeline_ManualTriggerData{},
+64
lexicons/label/op.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.label.op", 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 + "add", 15 + "delete", 16 + "performedAt" 17 + ], 18 + "properties": { 19 + "subject": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op." 23 + }, 24 + "performedAt": { 25 + "type": "string", 26 + "format": "datetime" 27 + }, 28 + "add": { 29 + "type": "array", 30 + "items": { 31 + "type": "ref", 32 + "ref": "#operand" 33 + } 34 + }, 35 + "delete": { 36 + "type": "array", 37 + "items": { 38 + "type": "ref", 39 + "ref": "#operand" 40 + } 41 + } 42 + } 43 + } 44 + }, 45 + "operand": { 46 + "type": "object", 47 + "required": [ 48 + "key", 49 + "value" 50 + ], 51 + "properties": { 52 + "key": { 53 + "type": "string", 54 + "format": "at-uri", 55 + "description": "ATURI to the label definition" 56 + }, 57 + "value": { 58 + "type": "string", 59 + "description": "Stringified value of the label. This is first unstringed by appviews and then interpreted as a concrete value." 60 + } 61 + } 62 + } 63 + } 64 + }
-36
knotserver/util.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 - "net/http" 5 - "os" 6 - "path/filepath" 7 - 8 4 "github.com/bluesky-social/indigo/atproto/syntax" 9 - securejoin "github.com/cyphar/filepath-securejoin" 10 - "github.com/go-chi/chi/v5" 11 5 ) 12 6 13 - func didPath(r *http.Request) string { 14 - did := chi.URLParam(r, "did") 15 - name := chi.URLParam(r, "name") 16 - path, _ := securejoin.SecureJoin(did, name) 17 - filepath.Clean(path) 18 - return path 19 - } 20 - 21 - func getDescription(path string) (desc string) { 22 - db, err := os.ReadFile(filepath.Join(path, "description")) 23 - if err == nil { 24 - desc = string(db) 25 - } else { 26 - desc = "" 27 - } 28 - return 29 - } 30 - func setContentDisposition(w http.ResponseWriter, name string) { 31 - h := "inline; filename=\"" + name + "\"" 32 - w.Header().Add("Content-Disposition", h) 33 - } 34 - 35 - func setGZipMIME(w http.ResponseWriter) { 36 - setMIME(w, "application/gzip") 37 - } 38 - 39 - func setMIME(w http.ResponseWriter, mime string) { 40 - w.Header().Add("Content-Type", mime) 41 - } 42 - 43 7 var TIDClock = syntax.NewTIDClock(0) 44 8 45 9 func TID() string {
+8 -5
lexicons/repo/repo.json
··· 12 12 "required": [ 13 13 "name", 14 14 "knot", 15 - "owner", 16 15 "createdAt" 17 16 ], 18 17 "properties": { ··· 20 19 "type": "string", 21 20 "description": "name of the repo" 22 21 }, 23 - "owner": { 24 - "type": "string", 25 - "format": "did" 26 - }, 27 22 "knot": { 28 23 "type": "string", 29 24 "description": "knot where the repo was created" ··· 42 37 "format": "uri", 43 38 "description": "source of the repo" 44 39 }, 40 + "labels": { 41 + "type": "array", 42 + "description": "List of labels that this repo subscribes to", 43 + "items": { 44 + "type": "string", 45 + "format": "at-uri" 46 + } 47 + }, 45 48 "createdAt": { 46 49 "type": "string", 47 50 "format": "datetime"
+1 -1
spindle/xrpc/add_secret.go
··· 62 62 } 63 63 64 64 repo := resp.Value.Val.(*tangled.Repo) 65 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 65 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 66 66 if err != nil { 67 67 fail(xrpcerr.GenericError(err)) 68 68 return
+1 -1
spindle/xrpc/list_secrets.go
··· 57 57 } 58 58 59 59 repo := resp.Value.Val.(*tangled.Repo) 60 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 61 61 if err != nil { 62 62 fail(xrpcerr.GenericError(err)) 63 63 return
+1 -1
spindle/xrpc/remove_secret.go
··· 56 56 } 57 57 58 58 repo := resp.Value.Val.(*tangled.Repo) 59 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 59 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 60 60 if err != nil { 61 61 fail(xrpcerr.GenericError(err)) 62 62 return
+6
appview/pages/templates/repo/fragments/colorBall.html
··· 1 + {{ define "repo/fragments/colorBall" }} 2 + <div 3 + class="size-2 rounded-full {{ .classes }}" 4 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ .color }} 70%, white), {{ .color }} 30%, color-mix(in srgb, {{ .color }} 85%, black));" 5 + ></div> 6 + {{ end }}
+1 -1
appview/pages/templates/repo/index.html
··· 49 49 <div 50 50 class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center" 51 51 > 52 - {{ template "repo/fragments/languageBall" $value.Name }} 52 + {{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }} 53 53 <div>{{ or $value.Name "Other" }} 54 54 <span class="text-gray-500 dark:text-gray-400"> 55 55 {{ if lt $value.Percentage 0.05 }}
+1 -1
appview/pages/templates/user/overview.html
··· 73 73 {{ with .Repo.RepoStats }} 74 74 {{ with .Language }} 75 75 <div class="flex gap-2 items-center text-xs font-mono text-gray-400 "> 76 - {{ template "repo/fragments/languageBall" . }} 76 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 77 77 <span>{{ . }}</span> 78 78 </div> 79 79 {{end }}
+1 -1
appview/issues/router.go
··· 14 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 15 16 16 r.Route("/{issue}", func(r chi.Router) { 17 - r.Use(mw.ResolveIssue()) 17 + r.Use(mw.ResolveIssue) 18 18 r.Get("/", i.RepoSingleIssue) 19 19 20 20 // authenticated routes
+1 -1
appview/pages/templates/repo/settings/pipelines.html
··· 109 109 hx-swap="none" 110 110 class="flex flex-col gap-2" 111 111 > 112 - <p class="uppercase p-0">ADD SECRET</p> 112 + <p class="uppercase p-0 font-bold">ADD SECRET</p> 113 113 <p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p> 114 114 <input 115 115 type="text"
+6
appview/pages/templates/labels/fragments/labelDef.html
··· 1 + {{ define "labels/fragments/labelDef" }} 2 + <span class="flex items-center gap-2 font-normal normal-case"> 3 + {{ template "repo/fragments/colorBall" (dict "color" .GetColor) }} 4 + {{ .Name }} 5 + </span> 6 + {{ end }}
+6 -8
appview/pages/templates/layouts/repobase.html
··· 41 41 {{ template "repo/fragments/repoDescription" . }} 42 42 </section> 43 43 44 - <section 45 - class="w-full flex flex-col" 46 - > 44 + <section class="w-full flex flex-col" > 47 45 <nav class="w-full pl-4 overflow-auto"> 48 46 <div class="flex z-60"> 49 47 {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} ··· 80 78 {{ end }} 81 79 </div> 82 80 </nav> 83 - <section 84 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white" 85 - > 81 + {{ block "repoContentLayout" . }} 82 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 86 83 {{ block "repoContent" . }}{{ end }} 87 - </section> 88 - {{ block "repoAfter" . }}{{ end }} 84 + </section> 85 + {{ block "repoAfter" . }}{{ end }} 86 + {{ end }} 89 87 </section> 90 88 {{ end }}
+4 -4
appview/pages/templates/repo/issues/fragments/commentList.html
··· 3 3 {{ range $item := .CommentList }} 4 4 {{ template "commentListing" (list $ .) }} 5 5 {{ end }} 6 - <div> 6 + </div> 7 7 {{ end }} 8 8 9 9 {{ define "commentListing" }} ··· 16 16 "Issue" $root.Issue 17 17 "Comment" $comment.Self) }} 18 18 19 - <div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm"> 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 20 {{ template "topLevelComment" $params }} 21 21 22 - <div class="relative ml-4 border-l border-gray-300 dark:border-gray-700"> 22 + <div class="relative ml-4 border-l-2 border-gray-200 dark:border-gray-700"> 23 23 {{ range $index, $reply := $comment.Replies }} 24 24 <div class="relative "> 25 25 <!-- Horizontal connector --> 26 - <div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div> 26 + <div class="absolute left-0 top-6 w-4 h-1 bg-gray-200 dark:bg-gray-700"></div> 27 27 28 28 <div class="pl-2"> 29 29 {{
-127
appview/pages/templates/repo/fragments/addLabelModal.html
··· 1 - {{ define "repo/fragments/addLabelModal" }} 2 - {{ $root := .root }} 3 - {{ $subject := .subject }} 4 - {{ $state := .state }} 5 - {{ with $root }} 6 - <form 7 - hx-put="/{{ .RepoInfo.FullName }}/labels/perform" 8 - hx-on::after-request="this.reset()" 9 - hx-indicator="#spinner" 10 - hx-swap="none" 11 - class="flex flex-col gap-4" 12 - > 13 - <p class="text-gray-500 dark:text-gray-400">Add, remove or update labels.</p> 14 - 15 - <input class="hidden" name="repo" value="{{ .RepoInfo.RepoAt.String }}"> 16 - <input class="hidden" name="subject" value="{{ $subject }}"> 17 - 18 - <div class="flex flex-col gap-2"> 19 - {{ $id := 0 }} 20 - {{ range $k, $valset := $state.Inner }} 21 - {{ $d := index $root.LabelDefs $k }} 22 - {{ range $v, $s := $valset }} 23 - {{ template "labelCheckbox" (dict "def" $d "key" $k "val" $v "id" $id "isChecked" true) }} 24 - {{ $id = add $id 1 }} 25 - {{ end }} 26 - {{ end }} 27 - 28 - {{ range $k, $d := $root.LabelDefs }} 29 - {{ if not ($state.ContainsLabel $k) }} 30 - {{ template "labelCheckbox" (dict "def" $d "key" $k "val" "" "id" $id "isChecked" false) }} 31 - {{ $id = add $id 1 }} 32 - {{ end }} 33 - {{ else }} 34 - <span> 35 - No labels defined yet. You can define custom labels in <a class="underline" href="/{{ .RepoInfo.FullName }}/settings">settings</a>. 36 - </span> 37 - {{ end }} 38 - </div> 39 - 40 - <div class="flex gap-2 pt-2"> 41 - <button 42 - type="button" 43 - popovertarget="add-label-modal" 44 - popovertargetaction="hide" 45 - class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 46 - > 47 - {{ i "x" "size-4" }} cancel 48 - </button> 49 - <button type="submit" class="btn w-1/2 flex items-center"> 50 - <span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span> 51 - <span id="spinner" class="group"> 52 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 - </span> 54 - </button> 55 - </div> 56 - <div id="add-label-error" class="text-red-500 dark:text-red-400"></div> 57 - </form> 58 - {{ end }} 59 - {{ end }} 60 - 61 - {{ define "labelCheckbox" }} 62 - {{ $key := .key }} 63 - {{ $val := .val }} 64 - {{ $def := .def }} 65 - {{ $id := .id }} 66 - {{ $isChecked := .isChecked }} 67 - <div class="grid grid-cols-[auto_1fr_50%] gap-2 items-center cursor-pointer"> 68 - <input type="checkbox" id="op-{{$id}}" name="op-{{$id}}" value="add" {{if $isChecked}}checked{{end}} class="peer"> 69 - <label for="op-{{$id}}" class="flex items-center gap-2 text-base">{{ template "labels/fragments/labelDef" $def }}</label> 70 - <div class="w-full hidden peer-checked:block">{{ template "valueTypeInput" (dict "valueType" $def.ValueType "value" $val "key" $key) }}</div> 71 - <input type="hidden" name="operand-key" value="{{ $key }}"> 72 - </div> 73 - {{ end }} 74 - 75 - {{ define "valueTypeInput" }} 76 - {{ $valueType := .valueType }} 77 - {{ $value := .value }} 78 - {{ $key := .key }} 79 - 80 - {{ if $valueType.IsEnumType }} 81 - {{ template "enumTypeInput" $ }} 82 - {{ else if $valueType.IsBool }} 83 - {{ template "boolTypeInput" $ }} 84 - {{ else if $valueType.IsInt }} 85 - {{ template "intTypeInput" $ }} 86 - {{ else if $valueType.IsString }} 87 - {{ template "stringTypeInput" $ }} 88 - {{ else if $valueType.IsNull }} 89 - {{ template "nullTypeInput" $ }} 90 - {{ end }} 91 - {{ end }} 92 - 93 - {{ define "enumTypeInput" }} 94 - {{ $valueType := .valueType }} 95 - {{ $value := .value }} 96 - <select name="operand-val" class="w-full p-1 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600"> 97 - {{ range $valueType.Enum }} 98 - <option value="{{.}}" {{ if eq $value . }} selected {{ end }}>{{.}}</option> 99 - {{ end }} 100 - </select> 101 - {{ end }} 102 - 103 - {{ define "boolTypeInput" }} 104 - {{ $value := .value }} 105 - <select name="operand-val" class="w-full p-1 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600"> 106 - <option value="true" {{ if $value }} selected {{ end }}>true</option> 107 - <option value="false" {{ if not $value }} selected {{ end }}>false</option> 108 - </select> 109 - {{ end }} 110 - 111 - {{ define "intTypeInput" }} 112 - {{ $value := .value }} 113 - <input class="p-1 w-full" type="number" name="operand-val" value="{{$value}}" max="100"> 114 - {{ end }} 115 - 116 - {{ define "stringTypeInput" }} 117 - {{ $valueType := .valueType }} 118 - {{ $value := .value }} 119 - {{ if $valueType.IsDidFormat }} 120 - {{ $value = resolve .value }} 121 - {{ end }} 122 - <input class="p-1 w-full" type="text" name="operand-val" value="{{$value}}"> 123 - {{ end }} 124 - 125 - {{ define "nullTypeInput" }} 126 - <input class="p-1" type="hidden" name="operand-val" value="null"> 127 - {{ end }}
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
··· 1 + {{ define "repo/fragments/editLabelPanel" }} 2 + <form 3 + id="edit-label-panel" 4 + hx-put="/{{ .RepoInfo.FullName }}/labels/perform" 5 + hx-indicator="#spinner" 6 + hx-disabled-elt="#save-btn,#cancel-btn" 7 + hx-swap="none" 8 + class="flex flex-col gap-6" 9 + > 10 + <input type="hidden" name="repo" value="{{ .RepoInfo.RepoAt }}"> 11 + <input type="hidden" name="subject" value="{{ .Subject }}"> 12 + {{ template "editBasicLabels" . }} 13 + {{ template "editKvLabels" . }} 14 + {{ template "editLabelPanelActions" . }} 15 + <div id="add-label-error" class="text-red-500 dark:text-red-400"></div> 16 + </form> 17 + {{ end }} 18 + 19 + {{ define "editBasicLabels" }} 20 + {{ $defs := .Defs }} 21 + {{ $subject := .Subject }} 22 + {{ $state := .State }} 23 + {{ $labelStyle := "flex items-center gap-2 rounded py-1 px-2 border border-gray-200 dark:border-gray-700 text-sm bg-white dark:bg-gray-800 text-black dark:text-white" }} 24 + <div> 25 + {{ template "repo/fragments/labelSectionHeaderText" "Labels" }} 26 + 27 + <div class="flex gap-1 items-center flex-wrap"> 28 + {{ range $k, $d := $defs }} 29 + {{ $isChecked := $state.ContainsLabel $k }} 30 + {{ if $d.ValueType.IsNull }} 31 + {{ $fieldName := $d.AtUri }} 32 + <label class="{{$labelStyle}}"> 33 + <input type="checkbox" id="{{ $fieldName }}" name="{{ $fieldName }}" value="null" {{if $isChecked}}checked{{end}}> 34 + {{ template "labels/fragments/labelDef" $d }} 35 + </label> 36 + {{ end }} 37 + {{ else }} 38 + <p class="text-gray-500 dark:text-gray-400 text-sm py-1"> 39 + No labels defined yet. You can choose default labels or define custom 40 + labels in <a class="underline" href="/{{ $.RepoInfo.FullName }}/settings">settings</a>. 41 + </p> 42 + {{ end }} 43 + </div> 44 + </div> 45 + {{ end }} 46 + 47 + {{ define "editKvLabels" }} 48 + {{ $defs := .Defs }} 49 + {{ $subject := .Subject }} 50 + {{ $state := .State }} 51 + {{ $labelStyle := "font-normal normal-case flex items-center gap-2 p-0" }} 52 + 53 + {{ range $k, $d := $defs }} 54 + {{ if (not $d.ValueType.IsNull) }} 55 + {{ $fieldName := $d.AtUri }} 56 + {{ $valset := $state.GetValSet $k }} 57 + <div id="label-{{$d.Id}}" class="flex flex-col gap-1"> 58 + {{ template "repo/fragments/labelSectionHeaderText" $d.Name }} 59 + {{ if (and $d.Multiple $d.ValueType.IsEnum) }} 60 + <!-- checkbox --> 61 + {{ range $variant := $d.ValueType.Enum }} 62 + <label class="{{$labelStyle}}"> 63 + <input type="checkbox" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}> 64 + {{ $variant }} 65 + </label> 66 + {{ end }} 67 + {{ else if $d.Multiple }} 68 + <!-- dynamically growing input fields --> 69 + {{ range $v, $s := $valset }} 70 + {{ template "multipleInputField" (dict "def" $d "value" $v "key" $k) }} 71 + {{ else }} 72 + {{ template "multipleInputField" (dict "def" $d "value" "" "key" $k) }} 73 + {{ end }} 74 + {{ template "addFieldButton" $d }} 75 + {{ else if $d.ValueType.IsEnum }} 76 + <!-- radio buttons --> 77 + {{ $isUsed := $state.ContainsLabel $k }} 78 + {{ range $variant := $d.ValueType.Enum }} 79 + <label class="{{$labelStyle}}"> 80 + <input type="radio" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}> 81 + {{ $variant }} 82 + </label> 83 + {{ end }} 84 + <label class="{{$labelStyle}}"> 85 + <input type="radio" name="{{ $fieldName }}" value="" {{ if not $isUsed }}checked{{ end }}> 86 + None 87 + </label> 88 + {{ else }} 89 + <!-- single input field based on value type --> 90 + {{ range $v, $s := $valset }} 91 + {{ template "valueTypeInput" (dict "def" $d "value" $v "key" $k) }} 92 + {{ else }} 93 + {{ template "valueTypeInput" (dict "def" $d "value" "" "key" $k) }} 94 + {{ end }} 95 + {{ end }} 96 + </div> 97 + {{ end }} 98 + {{ end }} 99 + {{ end }} 100 + 101 + {{ define "multipleInputField" }} 102 + <div class="flex gap-1 items-stretch"> 103 + {{ template "valueTypeInput" . }} 104 + {{ template "removeFieldButton" }} 105 + </div> 106 + {{ end }} 107 + 108 + {{ define "addFieldButton" }} 109 + <div style="display:none" id="tpl-{{ .Id }}"> 110 + {{ template "multipleInputField" (dict "def" . "value" "" "key" .AtUri.String) }} 111 + </div> 112 + <button type="button" onClick="this.insertAdjacentHTML('beforebegin', document.getElementById('tpl-{{ .Id }}').innerHTML)" class="w-full btn flex items-center gap-2"> 113 + {{ i "plus" "size-4" }} add 114 + </button> 115 + {{ end }} 116 + 117 + {{ define "removeFieldButton" }} 118 + <button type="button" onClick="this.parentElement.remove()" class="btn flex items-center gap-2 text-red-400 dark:text-red-500"> 119 + {{ i "trash-2" "size-4" }} 120 + </button> 121 + {{ end }} 122 + 123 + {{ define "valueTypeInput" }} 124 + {{ $def := .def }} 125 + {{ $valueType := $def.ValueType }} 126 + {{ $value := .value }} 127 + {{ $key := .key }} 128 + 129 + {{ if $valueType.IsBool }} 130 + {{ template "boolTypeInput" $ }} 131 + {{ else if $valueType.IsInt }} 132 + {{ template "intTypeInput" $ }} 133 + {{ else if $valueType.IsString }} 134 + {{ template "stringTypeInput" $ }} 135 + {{ else if $valueType.IsNull }} 136 + {{ template "nullTypeInput" $ }} 137 + {{ end }} 138 + {{ end }} 139 + 140 + {{ define "boolTypeInput" }} 141 + {{ $def := .def }} 142 + {{ $fieldName := $def.AtUri }} 143 + {{ $value := .value }} 144 + {{ $labelStyle = "font-normal normal-case flex items-center gap-2" }} 145 + <div class="flex flex-col gap-1"> 146 + <label class="{{$labelStyle}}"> 147 + <input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}> 148 + None 149 + </label> 150 + <label class="{{$labelStyle}}"> 151 + <input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}> 152 + None 153 + </label> 154 + <label class="{{$labelStyle}}"> 155 + <input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}> 156 + None 157 + </label> 158 + </div> 159 + {{ end }} 160 + 161 + {{ define "intTypeInput" }} 162 + {{ $def := .def }} 163 + {{ $fieldName := $def.AtUri }} 164 + {{ $value := .value }} 165 + <input class="p-1 w-full" type="number" name="{{$fieldName}}" value="{{$value}}"> 166 + {{ end }} 167 + 168 + {{ define "stringTypeInput" }} 169 + {{ $def := .def }} 170 + {{ $fieldName := $def.AtUri }} 171 + {{ $valueType := $def.ValueType }} 172 + {{ $value := .value }} 173 + {{ if $valueType.IsDidFormat }} 174 + {{ $value = trimPrefix (resolve .value) "@" }} 175 + {{ end }} 176 + <input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}"> 177 + {{ end }} 178 + 179 + {{ define "nullTypeInput" }} 180 + {{ $def := .def }} 181 + {{ $fieldName := $def.AtUri }} 182 + <input class="p-1" type="hidden" name="{{$fieldName}}" value="null"> 183 + {{ end }} 184 + 185 + {{ define "editLabelPanelActions" }} 186 + <div class="flex gap-2 pt-2"> 187 + <button 188 + id="cancel-btn" 189 + type="button" 190 + hx-get="/{{ .RepoInfo.FullName }}/label" 191 + hx-vals='{"subject": "{{.Subject}}"}' 192 + hx-swap="outerHTML" 193 + hx-target="#edit-label-panel" 194 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 group"> 195 + {{ i "x" "size-4" }} cancel 196 + </button> 197 + 198 + <button 199 + id="save-btn" 200 + type="submit" 201 + class="btn w-1/2 flex items-center"> 202 + <span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span> 203 + <span id="spinner" class="group"> 204 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 205 + </span> 206 + </button> 207 + </div> 208 + {{ end }}
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
··· 1 + {{ define "repo/fragments/labelSectionHeader" }} 2 + 3 + <div class="flex justify-between items-center gap-2"> 4 + {{ template "repo/fragments/labelSectionHeaderText" .Name }} 5 + {{ if (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }} 6 + <a 7 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 8 + hx-get="/{{ .RepoInfo.FullName }}/label/edit" 9 + hx-vals='{"subject": "{{.Subject}}"}' 10 + hx-swap="outerHTML" 11 + hx-target="#label-panel"> 12 + {{ i "pencil" "size-3" }} 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
··· 1 + {{ define "repo/fragments/labelSectionHeaderText" }} 2 + <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">{{ . }}</span> 3 + {{ end }}
+138 -86
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
··· 1 1 {{ define "repo/settings/fragments/addLabelDefModal" }} 2 - <form 3 - hx-put="/{{ $.RepoInfo.FullName }}/settings/label" 4 - hx-indicator="#spinner" 5 - hx-swap="none" 6 - hx-on::after-request="if(event.detail.successful) this.reset()" 7 - class="flex flex-col gap-4" 8 - > 9 - <p class="text-gray-500 dark:text-gray-400">Labels can have a name and a value. Set the value type to "none" to create a simple label.</p> 2 + <div class="grid grid-cols-2"> 3 + <input type="radio" name="tab" id="basic-tab" value="basic" class="hidden peer/basic" checked> 4 + <input type="radio" name="tab" id="kv-tab" value="kv" class="hidden peer/kv"> 10 5 11 - <div class="w-full"> 12 - <label for="name">Name</label> 13 - <input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/> 6 + <!-- Labels as direct siblings --> 7 + {{ $base := "py-2 text-sm font-normal normal-case block hover:no-underline text-center cursor-pointer bg-gray-100 dark:bg-gray-800 shadow-inner border border-gray-200 dark:border-gray-700" }} 8 + <label for="basic-tab" class="{{$base}} peer-checked/basic:bg-white peer-checked/basic:dark:bg-gray-700 peer-checked/basic:shadow-sm rounded-l"> 9 + Basic Labels 10 + </label> 11 + <label for="kv-tab" class="{{$base}} peer-checked/kv:bg-white peer-checked/kv:dark:bg-gray-700 peer-checked/kv:shadow-sm rounded-r"> 12 + Key-value Labels 13 + </label> 14 + 15 + <!-- Basic Labels Content - direct sibling --> 16 + <div class="mt-4 hidden peer-checked/basic:block col-span-full"> 17 + {{ template "basicLabelDef" . }} 14 18 </div> 15 19 16 - <!-- Value Type --> 17 - <div class="w-full"> 18 - <label for="valueType">Value Type</label> 19 - <select id="value-type" name="valueType" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600"> 20 - <option value="null" selected>None</option> 21 - <option value="string">String</option> 22 - <option value="integer">Integer</option> 23 - <option value="boolean">Boolean</option> 24 - </select> 25 - <details id="constrain-values" class="group hidden"> 26 - <summary class="list-none cursor-pointer flex items-center gap-2 py-2"> 27 - <span class="group-open:hidden inline text-gray-500 dark:text-gray-400">{{ i "square-plus" "w-4 h-4" }}</span> 28 - <span class="hidden group-open:inline text-gray-500 dark:text-gray-400">{{ i "square-minus" "w-4 h-4" }}</span> 29 - <span>Constrain values</span> 30 - </summary> 31 - <label for="enumValues">Permitted values</label> 32 - <input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/> 33 - <p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Enter comma-separated list of permitted values.</p> 34 - 35 - <label for="valueFormat">String format</label> 36 - <select id="valueFormat" name="valueFormat" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600"> 37 - <option value="any" selected>Any</option> 38 - <option value="did">DID</option> 39 - </select> 40 - <p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Choose a string format.</p> 41 - </details> 20 + <!-- Key-value Labels Content - direct sibling --> 21 + <div class="mt-4 hidden peer-checked/kv:block col-span-full"> 22 + {{ template "kvLabelDef" . }} 42 23 </div> 43 24 44 - <!-- Scope --> 25 + <div id="add-label-error" class="text-red-500 dark:text-red-400 col-span-full"></div> 26 + </div> 27 + {{ end }} 28 + 29 + {{ define "basicLabelDef" }} 30 + <form 31 + hx-put="/{{ $.RepoInfo.FullName }}/settings/label" 32 + hx-indicator="#spinner" 33 + hx-swap="none" 34 + hx-on::after-request="if(event.detail.successful) this.reset()" 35 + class="flex flex-col space-y-4"> 36 + 37 + <p class="text-gray-500 dark:text-gray-400">These labels can have a name and a color.</p> 38 + 39 + {{ template "nameInput" . }} 40 + {{ template "scopeInput" . }} 41 + {{ template "colorInput" . }} 42 + 43 + <div class="flex gap-2 pt-2"> 44 + {{ template "cancelButton" . }} 45 + {{ template "submitButton" . }} 46 + </div> 47 + </form> 48 + {{ end }} 49 + 50 + {{ define "kvLabelDef" }} 51 + <form 52 + hx-put="/{{ $.RepoInfo.FullName }}/settings/label" 53 + hx-indicator="#spinner" 54 + hx-swap="none" 55 + hx-on::after-request="if(event.detail.successful) this.reset()" 56 + class="flex flex-col space-y-4"> 57 + 58 + <p class="text-gray-500 dark:text-gray-400"> 59 + These labels are more detailed, they can have a key and an associated 60 + value. You may define additional constraints on label values. 61 + </p> 62 + 63 + {{ template "nameInput" . }} 64 + {{ template "valueInput" . }} 65 + {{ template "multipleInput" . }} 66 + {{ template "scopeInput" . }} 67 + {{ template "colorInput" . }} 68 + 69 + <div class="flex gap-2 pt-2"> 70 + {{ template "cancelButton" . }} 71 + {{ template "submitButton" . }} 72 + </div> 73 + </form> 74 + {{ end }} 75 + 76 + {{ define "nameInput" }} 45 77 <div class="w-full"> 46 - <label for="scope">Scope</label> 47 - <select id="scope" name="scope" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600"> 48 - <option value="sh.tangled.repo.issue">Issues</option> 49 - <option value="sh.tangled.repo.pull">Pull Requests</option> 50 - </select> 78 + <label for="name">Name</label> 79 + <input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/> 51 80 </div> 81 + {{ end }} 52 82 53 - <!-- Color --> 83 + {{ define "colorInput" }} 54 84 <div class="w-full"> 55 85 <label for="color">Color</label> 56 86 <div class="grid grid-cols-4 grid-rows-2 place-items-center"> ··· 63 93 {{ end }} 64 94 </div> 65 95 </div> 96 + {{ end }} 66 97 67 - <!-- Multiple --> 68 - <div class="w-full flex flex-wrap gap-2"> 69 - <input type="checkbox" id="multiple" name="multiple" value="true" /> 70 - <span> 71 - Allow multiple values 72 - </span> 98 + {{ define "scopeInput" }} 99 + <div class="w-full"> 100 + <label>Scope</label> 101 + <label class="font-normal normal-case flex items-center gap-2 p-0"> 102 + <input type="checkbox" id="issue-scope" name="scope" value="sh.tangled.repo.issue" checked /> 103 + Issues 104 + </label> 105 + <label class="font-normal normal-case flex items-center gap-2 p-0"> 106 + <input type="checkbox" id="pulls-scope" name="scope" value="sh.tangled.repo.pull" checked /> 107 + Pull Requests 108 + </label> 109 + </div> 110 + {{ end }} 111 + 112 + {{ define "valueInput" }} 113 + <div class="w-full"> 114 + <label for="valueType">Value Type</label> 115 + <select id="value-type" name="valueType" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600"> 116 + <option value="string">String</option> 117 + <option value="integer">Integer</option> 118 + </select> 73 119 </div> 74 120 75 - <div class="flex gap-2 pt-2"> 76 - <button 77 - type="button" 78 - popovertarget="add-labeldef-modal" 79 - popovertargetaction="hide" 80 - class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 81 - > 82 - {{ i "x" "size-4" }} cancel 83 - </button> 84 - <button type="submit" class="btn w-1/2 flex items-center"> 85 - <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 86 - <span id="spinner" class="group"> 87 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 - </span> 89 - </button> 121 + <div class="w-full"> 122 + <label for="enumValues">Permitted values</label> 123 + <input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/> 124 + <p class="text-sm text-gray-400 dark:text-gray-500 mt-1"> 125 + Enter comma-separated list of permitted values, or leave empty to allow any value. 126 + </p> 90 127 </div> 91 - <div id="add-label-error" class="text-red-500 dark:text-red-400"></div> 92 - </form> 93 - 94 - <script> 95 - document.getElementById('value-type').addEventListener('change', function() { 96 - const constrainValues = document.getElementById('constrain-values'); 97 - const selectedValue = this.value; 98 - 99 - if (selectedValue === 'string') { 100 - constrainValues.classList.remove('hidden'); 101 - } else { 102 - constrainValues.classList.add('hidden'); 103 - constrainValues.removeAttribute('open'); 104 - document.getElementById('enumValues').value = ''; 105 - } 106 - }); 107 - 108 - function toggleDarkMode() { 109 - document.documentElement.classList.toggle('dark'); 110 - } 111 - </script> 128 + 129 + <div class="w-full"> 130 + <label for="valueFormat">String format</label> 131 + <select id="valueFormat" name="valueFormat" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600"> 132 + <option value="any" selected>Any</option> 133 + <option value="did">DID</option> 134 + </select> 135 + </div> 136 + {{ end }} 137 + 138 + {{ define "multipleInput" }} 139 + <div class="w-full flex flex-wrap gap-2"> 140 + <input type="checkbox" id="multiple" name="multiple" value="true" /> 141 + <span>Allow multiple values</span> 142 + </div> 112 143 {{ end }} 113 144 145 + {{ define "cancelButton" }} 146 + <button 147 + type="button" 148 + popovertarget="add-labeldef-modal" 149 + popovertargetaction="hide" 150 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 151 + > 152 + {{ i "x" "size-4" }} cancel 153 + </button> 154 + {{ end }} 155 + 156 + {{ define "submitButton" }} 157 + <button type="submit" class="btn-create w-1/2 flex items-center"> 158 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 159 + <span id="spinner" class="group"> 160 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 161 + </span> 162 + </button> 163 + {{ end }} 164 + 165 +
+25 -27
appview/pages/templates/repo/settings/fragments/labelListing.html
··· 1 1 {{ define "repo/settings/fragments/labelListing" }} 2 2 {{ $root := index . 0 }} 3 3 {{ $label := index . 1 }} 4 - <div id="label-{{$label.Id}}" class="flex items-center justify-between p-2 pl-4"> 5 - <div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]"> 6 - {{ template "labels/fragments/labelDef" $label }} 7 - <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 4 + <div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]"> 5 + {{ template "labels/fragments/labelDef" $label }} 6 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 7 + {{ if $label.ValueType.IsNull }} 8 + basic 9 + {{ else }} 8 10 {{ $label.ValueType.Type }} type 9 - {{ if $label.ValueType.IsEnumType }} 10 - <span class="before:content-['·'] before:select-none"></span> 11 - {{ join $label.ValueType.Enum ", " }} 12 - {{ end }} 13 - {{ if $label.ValueType.IsDidFormat }} 14 - <span class="before:content-['·'] before:select-none"></span> 15 - DID format 16 - {{ end }} 17 - </div> 11 + {{ end }} 12 + 13 + {{ if $label.ValueType.IsEnum }} 14 + <span class="before:content-['·'] before:select-none"></span> 15 + {{ join $label.ValueType.Enum ", " }} 16 + {{ end }} 17 + 18 + {{ if $label.ValueType.IsDidFormat }} 19 + <span class="before:content-['·'] before:select-none"></span> 20 + DID format 21 + {{ end }} 22 + 23 + {{ if $label.Multiple }} 24 + <span class="before:content-['·'] before:select-none"></span> 25 + multiple 26 + {{ end }} 27 + 28 + <span class="before:content-['·'] before:select-none"></span> 29 + {{ join $label.Scope ", " }} 18 30 </div> 19 - {{ if $root.RepoInfo.Roles.IsOwner }} 20 - <button 21 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 22 - title="Delete label" 23 - hx-delete="/{{ $root.RepoInfo.FullName }}/settings/label" 24 - hx-swap="none" 25 - hx-vals='{"label-id": "{{ $label.Id }}"}' 26 - hx-confirm="Are you sure you want to delete the label `{{ $label.Name }}`?" 27 - > 28 - {{ i "trash-2" "w-5 h-5" }} 29 - <span class="hidden md:inline">delete</span> 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </button> 32 - {{ end }} 33 31 </div> 34 32 {{ end }}
+9
consts/consts.go
··· 1 + package consts 2 + 3 + const ( 4 + TangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 5 + IcyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 6 + 7 + DefaultSpindle = "spindle.tangled.sh" 8 + DefaultKnot = "knot1.tangled.sh" 9 + )
+30
appview/models/artifact.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/go-git/go-git/v5/plumbing" 9 + "github.com/ipfs/go-cid" 10 + "tangled.org/core/api/tangled" 11 + ) 12 + 13 + type Artifact struct { 14 + Id uint64 15 + Did string 16 + Rkey string 17 + 18 + RepoAt syntax.ATURI 19 + Tag plumbing.Hash 20 + CreatedAt time.Time 21 + 22 + BlobCid cid.Cid 23 + Name string 24 + Size uint64 25 + MimeType string 26 + } 27 + 28 + func (a *Artifact) ArtifactAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey)) 30 + }
+21
appview/models/collaborator.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Collaborator struct { 10 + // identifiers for the record 11 + Id int64 12 + Did syntax.DID 13 + Rkey string 14 + 15 + // content 16 + SubjectDid syntax.DID 17 + RepoAt syntax.ATURI 18 + 19 + // meta 20 + Created time.Time 21 + }
+16
appview/models/email.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Email struct { 8 + ID int64 9 + Did string 10 + Address string 11 + Verified bool 12 + Primary bool 13 + VerificationCode string 14 + LastSent *time.Time 15 + CreatedAt time.Time 16 + }
+26 -57
appview/db/follow.go
··· 5 5 "log" 6 6 "strings" 7 7 "time" 8 - ) 9 8 10 - type Follow struct { 11 - UserDid string 12 - SubjectDid string 13 - FollowedAt time.Time 14 - Rkey string 15 - } 9 + "tangled.org/core/appview/models" 10 + ) 16 11 17 - func AddFollow(e Execer, follow *Follow) error { 12 + func AddFollow(e Execer, follow *models.Follow) error { 18 13 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 19 14 _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 20 15 return err 21 16 } 22 17 23 18 // Get a follow record 24 - func GetFollow(e Execer, userDid, subjectDid string) (*Follow, error) { 19 + func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) { 25 20 query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?` 26 21 row := e.QueryRow(query, userDid, subjectDid) 27 22 28 - var follow Follow 23 + var follow models.Follow 29 24 var followedAt string 30 25 err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey) 31 26 if err != nil { ··· 55 50 return err 56 51 } 57 52 58 - type FollowStats struct { 59 - Followers int64 60 - Following int64 61 - } 62 - 63 - func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 53 + func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) { 64 54 var followers, following int64 65 55 err := e.QueryRow( 66 56 `SELECT ··· 68 58 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 69 59 FROM follows;`, did, did).Scan(&followers, &following) 70 60 if err != nil { 71 - return FollowStats{}, err 61 + return models.FollowStats{}, err 72 62 } 73 - return FollowStats{ 63 + return models.FollowStats{ 74 64 Followers: followers, 75 65 Following: following, 76 66 }, nil 77 67 } 78 68 79 - func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 69 + func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.FollowStats, error) { 80 70 if len(dids) == 0 { 81 71 return nil, nil 82 72 } ··· 112 102 ) g on f.did = g.did`, 113 103 placeholderStr, placeholderStr) 114 104 115 - result := make(map[string]FollowStats) 105 + result := make(map[string]models.FollowStats) 116 106 117 107 rows, err := e.Query(query, args...) 118 108 if err != nil { ··· 126 116 if err := rows.Scan(&did, &followers, &following); err != nil { 127 117 return nil, err 128 118 } 129 - result[did] = FollowStats{ 119 + result[did] = models.FollowStats{ 130 120 Followers: followers, 131 121 Following: following, 132 122 } ··· 134 124 135 125 for _, did := range dids { 136 126 if _, exists := result[did]; !exists { 137 - result[did] = FollowStats{ 127 + result[did] = models.FollowStats{ 138 128 Followers: 0, 139 129 Following: 0, 140 130 } ··· 144 134 return result, nil 145 135 } 146 136 147 - func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 148 - var follows []Follow 137 + func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) { 138 + var follows []models.Follow 149 139 150 140 var conditions []string 151 141 var args []any ··· 177 167 return nil, err 178 168 } 179 169 for rows.Next() { 180 - var follow Follow 170 + var follow models.Follow 181 171 var followedAt string 182 172 err := rows.Scan( 183 173 &follow.UserDid, ··· 200 190 return follows, nil 201 191 } 202 192 203 - func GetFollowers(e Execer, did string) ([]Follow, error) { 193 + func GetFollowers(e Execer, did string) ([]models.Follow, error) { 204 194 return GetFollows(e, 0, FilterEq("subject_did", did)) 205 195 } 206 196 207 - func GetFollowing(e Execer, did string) ([]Follow, error) { 197 + func GetFollowing(e Execer, did string) ([]models.Follow, error) { 208 198 return GetFollows(e, 0, FilterEq("user_did", did)) 209 199 } 210 200 211 - type FollowStatus int 212 - 213 - const ( 214 - IsNotFollowing FollowStatus = iota 215 - IsFollowing 216 - IsSelf 217 - ) 218 - 219 - func (s FollowStatus) String() string { 220 - switch s { 221 - case IsNotFollowing: 222 - return "IsNotFollowing" 223 - case IsFollowing: 224 - return "IsFollowing" 225 - case IsSelf: 226 - return "IsSelf" 227 - default: 228 - return "IsNotFollowing" 229 - } 230 - } 231 - 232 - func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) { 201 + func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) { 233 202 if len(subjectDids) == 0 || userDid == "" { 234 - return make(map[string]FollowStatus), nil 203 + return make(map[string]models.FollowStatus), nil 235 204 } 236 205 237 - result := make(map[string]FollowStatus) 206 + result := make(map[string]models.FollowStatus) 238 207 239 208 for _, subjectDid := range subjectDids { 240 209 if userDid == subjectDid { 241 - result[subjectDid] = IsSelf 210 + result[subjectDid] = models.IsSelf 242 211 } else { 243 - result[subjectDid] = IsNotFollowing 212 + result[subjectDid] = models.IsNotFollowing 244 213 } 245 214 } 246 215 ··· 281 250 if err := rows.Scan(&subjectDid); err != nil { 282 251 return nil, err 283 252 } 284 - result[subjectDid] = IsFollowing 253 + result[subjectDid] = models.IsFollowing 285 254 } 286 255 287 256 return result, nil 288 257 } 289 258 290 - func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 259 + func GetFollowStatus(e Execer, userDid, subjectDid string) models.FollowStatus { 291 260 statuses, err := getFollowStatuses(e, userDid, []string{subjectDid}) 292 261 if err != nil { 293 - return IsNotFollowing 262 + return models.IsNotFollowing 294 263 } 295 264 return statuses[subjectDid] 296 265 } 297 266 298 - func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) { 267 + func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) { 299 268 return getFollowStatuses(e, userDid, subjectDids) 300 269 }
+38
appview/models/follow.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Follow struct { 8 + UserDid string 9 + SubjectDid string 10 + FollowedAt time.Time 11 + Rkey string 12 + } 13 + 14 + type FollowStats struct { 15 + Followers int64 16 + Following int64 17 + } 18 + 19 + type FollowStatus int 20 + 21 + const ( 22 + IsNotFollowing FollowStatus = iota 23 + IsFollowing 24 + IsSelf 25 + ) 26 + 27 + func (s FollowStatus) String() string { 28 + switch s { 29 + case IsNotFollowing: 30 + return "IsNotFollowing" 31 + case IsFollowing: 32 + return "IsFollowing" 33 + case IsSelf: 34 + return "IsSelf" 35 + default: 36 + return "IsNotFollowing" 37 + } 38 + }
+3 -3
appview/pages/repoinfo/repoinfo.go
··· 7 7 "strings" 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/models" 11 11 "tangled.org/core/appview/state/userutil" 12 12 ) 13 13 ··· 60 60 Spindle string 61 61 RepoAt syntax.ATURI 62 62 IsStarred bool 63 - Stats db.RepoStats 63 + Stats models.RepoStats 64 64 Roles RolesInRepo 65 - Source *db.Repo 65 + Source *models.Repo 66 66 SourceHandle string 67 67 Ref string 68 68 DisableFork bool
+4 -4
appview/state/git_http.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/identity" 10 10 "github.com/go-chi/chi/v5" 11 - "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/models" 12 12 ) 13 13 14 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 15 user := r.Context().Value("resolvedId").(identity.Identity) 16 - repo := r.Context().Value("repo").(*db.Repo) 16 + repo := r.Context().Value("repo").(*models.Repo) 17 17 18 18 scheme := "https" 19 19 if s.config.Core.Dev { ··· 31 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 32 return 33 33 } 34 - repo := r.Context().Value("repo").(*db.Repo) 34 + repo := r.Context().Value("repo").(*models.Repo) 35 35 36 36 scheme := "https" 37 37 if s.config.Core.Dev { ··· 48 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 49 return 50 50 } 51 - repo := r.Context().Value("repo").(*db.Repo) 51 + repo := r.Context().Value("repo").(*models.Repo) 52 52 53 53 scheme := "https" 54 54 if s.config.Core.Dev {
+15 -200
appview/db/issues.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.org/core/api/tangled" 14 13 "tangled.org/core/appview/models" 15 14 "tangled.org/core/appview/pagination" 16 15 ) 17 16 18 - type Issue struct { 19 - Id int64 20 - Did string 21 - Rkey string 22 - RepoAt syntax.ATURI 23 - IssueId int 24 - Created time.Time 25 - Edited *time.Time 26 - Deleted *time.Time 27 - Title string 28 - Body string 29 - Open bool 30 - 31 - // optionally, populate this when querying for reverse mappings 32 - // like comment counts, parent repo etc. 33 - Comments []IssueComment 34 - Labels models.LabelState 35 - Repo *models.Repo 36 - } 37 - 38 - func (i *Issue) AtUri() syntax.ATURI { 39 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 40 - } 41 - 42 - func (i *Issue) AsRecord() tangled.RepoIssue { 43 - return tangled.RepoIssue{ 44 - Repo: i.RepoAt.String(), 45 - Title: i.Title, 46 - Body: &i.Body, 47 - CreatedAt: i.Created.Format(time.RFC3339), 48 - } 49 - } 50 - 51 - func (i *Issue) State() string { 52 - if i.Open { 53 - return "open" 54 - } 55 - return "closed" 56 - } 57 - 58 - type CommentListItem struct { 59 - Self *IssueComment 60 - Replies []*IssueComment 61 - } 62 - 63 - func (i *Issue) CommentList() []CommentListItem { 64 - // Create a map to quickly find comments by their aturi 65 - toplevel := make(map[string]*CommentListItem) 66 - var replies []*IssueComment 67 - 68 - // collect top level comments into the map 69 - for _, comment := range i.Comments { 70 - if comment.IsTopLevel() { 71 - toplevel[comment.AtUri().String()] = &CommentListItem{ 72 - Self: &comment, 73 - } 74 - } else { 75 - replies = append(replies, &comment) 76 - } 77 - } 78 - 79 - for _, r := range replies { 80 - parentAt := *r.ReplyTo 81 - if parent, exists := toplevel[parentAt]; exists { 82 - parent.Replies = append(parent.Replies, r) 83 - } 84 - } 85 - 86 - var listing []CommentListItem 87 - for _, v := range toplevel { 88 - listing = append(listing, *v) 89 - } 90 - 91 - // sort everything 92 - sortFunc := func(a, b *IssueComment) bool { 93 - return a.Created.Before(b.Created) 94 - } 95 - sort.Slice(listing, func(i, j int) bool { 96 - return sortFunc(listing[i].Self, listing[j].Self) 97 - }) 98 - for _, r := range listing { 99 - sort.Slice(r.Replies, func(i, j int) bool { 100 - return sortFunc(r.Replies[i], r.Replies[j]) 101 - }) 102 - } 103 - 104 - return listing 105 - } 106 - 107 - func (i *Issue) Participants() []string { 108 - participantSet := make(map[string]struct{}) 109 - participants := []string{} 110 - 111 - addParticipant := func(did string) { 112 - if _, exists := participantSet[did]; !exists { 113 - participantSet[did] = struct{}{} 114 - participants = append(participants, did) 115 - } 116 - } 117 - 118 - addParticipant(i.Did) 119 - 120 - for _, c := range i.Comments { 121 - addParticipant(c.Did) 122 - } 123 - 124 - return participants 125 - } 126 - 127 - func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 128 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 129 - if err != nil { 130 - created = time.Now() 131 - } 132 - 133 - body := "" 134 - if record.Body != nil { 135 - body = *record.Body 136 - } 137 - 138 - return Issue{ 139 - RepoAt: syntax.ATURI(record.Repo), 140 - Did: did, 141 - Rkey: rkey, 142 - Created: created, 143 - Title: record.Title, 144 - Body: body, 145 - Open: true, // new issues are open by default 146 - } 147 - } 148 - 149 - type IssueComment struct { 150 - Id int64 151 - Did string 152 - Rkey string 153 - IssueAt string 154 - ReplyTo *string 155 - Body string 156 - Created time.Time 157 - Edited *time.Time 158 - Deleted *time.Time 159 - } 160 - 161 - func (i *IssueComment) AtUri() syntax.ATURI { 162 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 163 - } 164 - 165 - func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 166 - return tangled.RepoIssueComment{ 167 - Body: i.Body, 168 - Issue: i.IssueAt, 169 - CreatedAt: i.Created.Format(time.RFC3339), 170 - ReplyTo: i.ReplyTo, 171 - } 172 - } 173 - 174 - func (i *IssueComment) IsTopLevel() bool { 175 - return i.ReplyTo == nil 176 - } 177 - 178 - func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 179 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 180 - if err != nil { 181 - created = time.Now() 182 - } 183 - 184 - ownerDid := did 185 - 186 - if _, err = syntax.ParseATURI(record.Issue); err != nil { 187 - return nil, err 188 - } 189 - 190 - comment := IssueComment{ 191 - Did: ownerDid, 192 - Rkey: rkey, 193 - Body: record.Body, 194 - IssueAt: record.Issue, 195 - ReplyTo: record.ReplyTo, 196 - Created: created, 197 - } 198 - 199 - return &comment, nil 200 - } 201 - 202 - func PutIssue(tx *sql.Tx, issue *Issue) error { 17 + func PutIssue(tx *sql.Tx, issue *models.Issue) error { 203 18 // ensure sequence exists 204 19 _, err := tx.Exec(` 205 20 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) ··· 234 49 } 235 50 } 236 51 237 - func createNewIssue(tx *sql.Tx, issue *Issue) error { 52 + func createNewIssue(tx *sql.Tx, issue *models.Issue) error { 238 53 // get next issue_id 239 54 var newIssueId int 240 55 err := tx.QueryRow(` ··· 257 72 return row.Scan(&issue.Id, &issue.IssueId) 258 73 } 259 74 260 - func updateIssue(tx *sql.Tx, issue *Issue) error { 75 + func updateIssue(tx *sql.Tx, issue *models.Issue) error { 261 76 // update existing issue 262 77 _, err := tx.Exec(` 263 78 update issues ··· 267 82 return err 268 83 } 269 84 270 - func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 271 - issueMap := make(map[string]*Issue) // at-uri -> issue 85 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { 86 + issueMap := make(map[string]*models.Issue) // at-uri -> issue 272 87 273 88 var conditions []string 274 89 var args []any ··· 323 138 defer rows.Close() 324 139 325 140 for rows.Next() { 326 - var issue Issue 141 + var issue models.Issue 327 142 var createdAt string 328 143 var editedAt, deletedAt sql.Null[string] 329 144 var rowNum int64 ··· 416 231 } 417 232 } 418 233 419 - var issues []Issue 234 + var issues []models.Issue 420 235 for _, i := range issueMap { 421 236 issues = append(issues, *i) 422 237 } ··· 428 243 return issues, nil 429 244 } 430 245 431 - func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 246 + func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 432 247 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 433 248 } 434 249 435 - func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 250 + func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 436 251 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 437 252 row := e.QueryRow(query, repoAt, issueId) 438 253 439 - var issue Issue 254 + var issue models.Issue 440 255 var createdAt string 441 256 err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 442 257 if err != nil { ··· 452 267 return &issue, nil 453 268 } 454 269 455 - func AddIssueComment(e Execer, c IssueComment) (int64, error) { 270 + func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 456 271 result, err := e.Exec( 457 272 `insert into issue_comments ( 458 273 did, ··· 514 329 return err 515 330 } 516 331 517 - func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 518 - var comments []IssueComment 332 + func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 333 + var comments []models.IssueComment 519 334 520 335 var conditions []string 521 336 var args []any ··· 551 366 } 552 367 553 368 for rows.Next() { 554 - var comment IssueComment 369 + var comment models.IssueComment 555 370 var created string 556 371 var rkey, edited, deleted, replyTo sql.Null[string] 557 372 err := rows.Scan( ··· 670 485 671 486 var count models.IssueCount 672 487 if err := row.Scan(&count.Open, &count.Closed); err != nil { 673 - return models.IssueCount{0, 0}, err 488 + return models.IssueCount{}, err 674 489 } 675 490 676 491 return count, nil
+194
appview/models/issue.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "sort" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + ) 11 + 12 + type Issue struct { 13 + Id int64 14 + Did string 15 + Rkey string 16 + RepoAt syntax.ATURI 17 + IssueId int 18 + Created time.Time 19 + Edited *time.Time 20 + Deleted *time.Time 21 + Title string 22 + Body string 23 + Open bool 24 + 25 + // optionally, populate this when querying for reverse mappings 26 + // like comment counts, parent repo etc. 27 + Comments []IssueComment 28 + Labels LabelState 29 + Repo *Repo 30 + } 31 + 32 + func (i *Issue) AtUri() syntax.ATURI { 33 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 34 + } 35 + 36 + func (i *Issue) AsRecord() tangled.RepoIssue { 37 + return tangled.RepoIssue{ 38 + Repo: i.RepoAt.String(), 39 + Title: i.Title, 40 + Body: &i.Body, 41 + CreatedAt: i.Created.Format(time.RFC3339), 42 + } 43 + } 44 + 45 + func (i *Issue) State() string { 46 + if i.Open { 47 + return "open" 48 + } 49 + return "closed" 50 + } 51 + 52 + type CommentListItem struct { 53 + Self *IssueComment 54 + Replies []*IssueComment 55 + } 56 + 57 + func (i *Issue) CommentList() []CommentListItem { 58 + // Create a map to quickly find comments by their aturi 59 + toplevel := make(map[string]*CommentListItem) 60 + var replies []*IssueComment 61 + 62 + // collect top level comments into the map 63 + for _, comment := range i.Comments { 64 + if comment.IsTopLevel() { 65 + toplevel[comment.AtUri().String()] = &CommentListItem{ 66 + Self: &comment, 67 + } 68 + } else { 69 + replies = append(replies, &comment) 70 + } 71 + } 72 + 73 + for _, r := range replies { 74 + parentAt := *r.ReplyTo 75 + if parent, exists := toplevel[parentAt]; exists { 76 + parent.Replies = append(parent.Replies, r) 77 + } 78 + } 79 + 80 + var listing []CommentListItem 81 + for _, v := range toplevel { 82 + listing = append(listing, *v) 83 + } 84 + 85 + // sort everything 86 + sortFunc := func(a, b *IssueComment) bool { 87 + return a.Created.Before(b.Created) 88 + } 89 + sort.Slice(listing, func(i, j int) bool { 90 + return sortFunc(listing[i].Self, listing[j].Self) 91 + }) 92 + for _, r := range listing { 93 + sort.Slice(r.Replies, func(i, j int) bool { 94 + return sortFunc(r.Replies[i], r.Replies[j]) 95 + }) 96 + } 97 + 98 + return listing 99 + } 100 + 101 + func (i *Issue) Participants() []string { 102 + participantSet := make(map[string]struct{}) 103 + participants := []string{} 104 + 105 + addParticipant := func(did string) { 106 + if _, exists := participantSet[did]; !exists { 107 + participantSet[did] = struct{}{} 108 + participants = append(participants, did) 109 + } 110 + } 111 + 112 + addParticipant(i.Did) 113 + 114 + for _, c := range i.Comments { 115 + addParticipant(c.Did) 116 + } 117 + 118 + return participants 119 + } 120 + 121 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 122 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 123 + if err != nil { 124 + created = time.Now() 125 + } 126 + 127 + body := "" 128 + if record.Body != nil { 129 + body = *record.Body 130 + } 131 + 132 + return Issue{ 133 + RepoAt: syntax.ATURI(record.Repo), 134 + Did: did, 135 + Rkey: rkey, 136 + Created: created, 137 + Title: record.Title, 138 + Body: body, 139 + Open: true, // new issues are open by default 140 + } 141 + } 142 + 143 + type IssueComment struct { 144 + Id int64 145 + Did string 146 + Rkey string 147 + IssueAt string 148 + ReplyTo *string 149 + Body string 150 + Created time.Time 151 + Edited *time.Time 152 + Deleted *time.Time 153 + } 154 + 155 + func (i *IssueComment) AtUri() syntax.ATURI { 156 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 157 + } 158 + 159 + func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 160 + return tangled.RepoIssueComment{ 161 + Body: i.Body, 162 + Issue: i.IssueAt, 163 + CreatedAt: i.Created.Format(time.RFC3339), 164 + ReplyTo: i.ReplyTo, 165 + } 166 + } 167 + 168 + func (i *IssueComment) IsTopLevel() bool { 169 + return i.ReplyTo == nil 170 + } 171 + 172 + func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 173 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 174 + if err != nil { 175 + created = time.Now() 176 + } 177 + 178 + ownerDid := did 179 + 180 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 181 + return nil, err 182 + } 183 + 184 + comment := IssueComment{ 185 + Did: ownerDid, 186 + Rkey: rkey, 187 + Body: record.Body, 188 + IssueAt: record.Issue, 189 + ReplyTo: record.ReplyTo, 190 + Created: created, 191 + } 192 + 193 + return &comment, nil 194 + }
+14
appview/models/language.go
··· 1 + package models 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/syntax" 5 + ) 6 + 7 + type RepoLanguage struct { 8 + Id int64 9 + RepoAt syntax.ATURI 10 + Ref string 11 + IsDefaultRef bool 12 + Language string 13 + Bytes int64 14 + }
-173
appview/db/oauth.go
··· 1 - package db 2 - 3 - type OAuthRequest struct { 4 - ID uint 5 - AuthserverIss string 6 - Handle string 7 - State string 8 - Did string 9 - PdsUrl string 10 - PkceVerifier string 11 - DpopAuthserverNonce string 12 - DpopPrivateJwk string 13 - } 14 - 15 - func SaveOAuthRequest(e Execer, oauthRequest OAuthRequest) error { 16 - _, err := e.Exec(` 17 - insert into oauth_requests ( 18 - auth_server_iss, 19 - state, 20 - handle, 21 - did, 22 - pds_url, 23 - pkce_verifier, 24 - dpop_auth_server_nonce, 25 - dpop_private_jwk 26 - ) values (?, ?, ?, ?, ?, ?, ?, ?)`, 27 - oauthRequest.AuthserverIss, 28 - oauthRequest.State, 29 - oauthRequest.Handle, 30 - oauthRequest.Did, 31 - oauthRequest.PdsUrl, 32 - oauthRequest.PkceVerifier, 33 - oauthRequest.DpopAuthserverNonce, 34 - oauthRequest.DpopPrivateJwk, 35 - ) 36 - return err 37 - } 38 - 39 - func GetOAuthRequestByState(e Execer, state string) (OAuthRequest, error) { 40 - var req OAuthRequest 41 - err := e.QueryRow(` 42 - select 43 - id, 44 - auth_server_iss, 45 - handle, 46 - state, 47 - did, 48 - pds_url, 49 - pkce_verifier, 50 - dpop_auth_server_nonce, 51 - dpop_private_jwk 52 - from oauth_requests 53 - where state = ?`, state).Scan( 54 - &req.ID, 55 - &req.AuthserverIss, 56 - &req.Handle, 57 - &req.State, 58 - &req.Did, 59 - &req.PdsUrl, 60 - &req.PkceVerifier, 61 - &req.DpopAuthserverNonce, 62 - &req.DpopPrivateJwk, 63 - ) 64 - return req, err 65 - } 66 - 67 - func DeleteOAuthRequestByState(e Execer, state string) error { 68 - _, err := e.Exec(` 69 - delete from oauth_requests 70 - where state = ?`, state) 71 - return err 72 - } 73 - 74 - type OAuthSession struct { 75 - ID uint 76 - Handle string 77 - Did string 78 - PdsUrl string 79 - AccessJwt string 80 - RefreshJwt string 81 - AuthServerIss string 82 - DpopPdsNonce string 83 - DpopAuthserverNonce string 84 - DpopPrivateJwk string 85 - Expiry string 86 - } 87 - 88 - func SaveOAuthSession(e Execer, session OAuthSession) error { 89 - _, err := e.Exec(` 90 - insert into oauth_sessions ( 91 - did, 92 - handle, 93 - pds_url, 94 - access_jwt, 95 - refresh_jwt, 96 - auth_server_iss, 97 - dpop_auth_server_nonce, 98 - dpop_private_jwk, 99 - expiry 100 - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 101 - session.Did, 102 - session.Handle, 103 - session.PdsUrl, 104 - session.AccessJwt, 105 - session.RefreshJwt, 106 - session.AuthServerIss, 107 - session.DpopAuthserverNonce, 108 - session.DpopPrivateJwk, 109 - session.Expiry, 110 - ) 111 - return err 112 - } 113 - 114 - func RefreshOAuthSession(e Execer, did string, accessJwt, refreshJwt, expiry string) error { 115 - _, err := e.Exec(` 116 - update oauth_sessions 117 - set access_jwt = ?, refresh_jwt = ?, expiry = ? 118 - where did = ?`, 119 - accessJwt, 120 - refreshJwt, 121 - expiry, 122 - did, 123 - ) 124 - return err 125 - } 126 - 127 - func GetOAuthSessionByDid(e Execer, did string) (*OAuthSession, error) { 128 - var session OAuthSession 129 - err := e.QueryRow(` 130 - select 131 - id, 132 - did, 133 - handle, 134 - pds_url, 135 - access_jwt, 136 - refresh_jwt, 137 - auth_server_iss, 138 - dpop_auth_server_nonce, 139 - dpop_private_jwk, 140 - expiry 141 - from oauth_sessions 142 - where did = ?`, did).Scan( 143 - &session.ID, 144 - &session.Did, 145 - &session.Handle, 146 - &session.PdsUrl, 147 - &session.AccessJwt, 148 - &session.RefreshJwt, 149 - &session.AuthServerIss, 150 - &session.DpopAuthserverNonce, 151 - &session.DpopPrivateJwk, 152 - &session.Expiry, 153 - ) 154 - return &session, err 155 - } 156 - 157 - func DeleteOAuthSessionByDid(e Execer, did string) error { 158 - _, err := e.Exec(` 159 - delete from oauth_sessions 160 - where did = ?`, did) 161 - return err 162 - } 163 - 164 - func UpdateDpopPdsNonce(e Execer, did string, dpopPdsNonce string) error { 165 - _, err := e.Exec(` 166 - update oauth_sessions 167 - set dpop_pds_nonce = ? 168 - where did = ?`, 169 - dpopPdsNonce, 170 - did, 171 - ) 172 - return err 173 - }
+177
appview/models/profile.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/api/tangled" 8 + ) 9 + 10 + type Profile struct { 11 + // ids 12 + ID int 13 + Did string 14 + 15 + // data 16 + Description string 17 + IncludeBluesky bool 18 + Location string 19 + Links [5]string 20 + Stats [2]VanityStat 21 + PinnedRepos [6]syntax.ATURI 22 + } 23 + 24 + func (p Profile) IsLinksEmpty() bool { 25 + for _, l := range p.Links { 26 + if l != "" { 27 + return false 28 + } 29 + } 30 + return true 31 + } 32 + 33 + func (p Profile) IsStatsEmpty() bool { 34 + for _, s := range p.Stats { 35 + if s.Kind != "" { 36 + return false 37 + } 38 + } 39 + return true 40 + } 41 + 42 + func (p Profile) IsPinnedReposEmpty() bool { 43 + for _, r := range p.PinnedRepos { 44 + if r != "" { 45 + return false 46 + } 47 + } 48 + return true 49 + } 50 + 51 + type VanityStatKind string 52 + 53 + const ( 54 + VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count" 55 + VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count" 56 + VanityStatOpenPRCount VanityStatKind = "open-pull-request-count" 57 + VanityStatOpenIssueCount VanityStatKind = "open-issue-count" 58 + VanityStatClosedIssueCount VanityStatKind = "closed-issue-count" 59 + VanityStatRepositoryCount VanityStatKind = "repository-count" 60 + ) 61 + 62 + func (v VanityStatKind) String() string { 63 + switch v { 64 + case VanityStatMergedPRCount: 65 + return "Merged PRs" 66 + case VanityStatClosedPRCount: 67 + return "Closed PRs" 68 + case VanityStatOpenPRCount: 69 + return "Open PRs" 70 + case VanityStatOpenIssueCount: 71 + return "Open Issues" 72 + case VanityStatClosedIssueCount: 73 + return "Closed Issues" 74 + case VanityStatRepositoryCount: 75 + return "Repositories" 76 + } 77 + return "" 78 + } 79 + 80 + type VanityStat struct { 81 + Kind VanityStatKind 82 + Value uint64 83 + } 84 + 85 + func (p *Profile) ProfileAt() syntax.ATURI { 86 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self")) 87 + } 88 + 89 + type RepoEvent struct { 90 + Repo *Repo 91 + Source *Repo 92 + } 93 + 94 + type ProfileTimeline struct { 95 + ByMonth []ByMonth 96 + } 97 + 98 + func (p *ProfileTimeline) IsEmpty() bool { 99 + if p == nil { 100 + return true 101 + } 102 + 103 + for _, m := range p.ByMonth { 104 + if !m.IsEmpty() { 105 + return false 106 + } 107 + } 108 + 109 + return true 110 + } 111 + 112 + type ByMonth struct { 113 + RepoEvents []RepoEvent 114 + IssueEvents IssueEvents 115 + PullEvents PullEvents 116 + } 117 + 118 + func (b ByMonth) IsEmpty() bool { 119 + return len(b.RepoEvents) == 0 && 120 + len(b.IssueEvents.Items) == 0 && 121 + len(b.PullEvents.Items) == 0 122 + } 123 + 124 + type IssueEvents struct { 125 + Items []*Issue 126 + } 127 + 128 + type IssueEventStats struct { 129 + Open int 130 + Closed int 131 + } 132 + 133 + func (i IssueEvents) Stats() IssueEventStats { 134 + var open, closed int 135 + for _, issue := range i.Items { 136 + if issue.Open { 137 + open += 1 138 + } else { 139 + closed += 1 140 + } 141 + } 142 + 143 + return IssueEventStats{ 144 + Open: open, 145 + Closed: closed, 146 + } 147 + } 148 + 149 + type PullEvents struct { 150 + Items []*Pull 151 + } 152 + 153 + func (p PullEvents) Stats() PullEventStats { 154 + var open, merged, closed int 155 + for _, pull := range p.Items { 156 + switch pull.State { 157 + case PullOpen: 158 + open += 1 159 + case PullMerged: 160 + merged += 1 161 + case PullClosed: 162 + closed += 1 163 + } 164 + } 165 + 166 + return PullEventStats{ 167 + Open: open, 168 + Merged: merged, 169 + Closed: closed, 170 + } 171 + } 172 + 173 + type PullEventStats struct { 174 + Closed int 175 + Open int 176 + Merged int 177 + }
+17 -139
appview/db/pipeline.go
··· 6 6 "strings" 7 7 "time" 8 8 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "github.com/go-git/go-git/v5/plumbing" 11 - spindle "tangled.org/core/spindle/models" 12 - "tangled.org/core/workflow" 9 + "tangled.org/core/appview/models" 13 10 ) 14 11 15 - type Pipeline struct { 16 - Id int 17 - Rkey string 18 - Knot string 19 - RepoOwner syntax.DID 20 - RepoName string 21 - TriggerId int 22 - Sha string 23 - Created time.Time 24 - 25 - // populate when querying for reverse mappings 26 - Trigger *Trigger 27 - Statuses map[string]WorkflowStatus 28 - } 29 - 30 - type WorkflowStatus struct { 31 - Data []PipelineStatus 32 - } 33 - 34 - func (w WorkflowStatus) Latest() PipelineStatus { 35 - return w.Data[len(w.Data)-1] 36 - } 37 - 38 - // time taken by this workflow to reach an "end state" 39 - func (w WorkflowStatus) TimeTaken() time.Duration { 40 - var start, end *time.Time 41 - for _, s := range w.Data { 42 - if s.Status.IsStart() { 43 - start = &s.Created 44 - } 45 - if s.Status.IsFinish() { 46 - end = &s.Created 47 - } 48 - } 49 - 50 - if start != nil && end != nil && end.After(*start) { 51 - return end.Sub(*start) 52 - } 53 - 54 - return 0 55 - } 56 - 57 - func (p Pipeline) Counts() map[string]int { 58 - m := make(map[string]int) 59 - for _, w := range p.Statuses { 60 - m[w.Latest().Status.String()] += 1 61 - } 62 - return m 63 - } 64 - 65 - func (p Pipeline) TimeTaken() time.Duration { 66 - var s time.Duration 67 - for _, w := range p.Statuses { 68 - s += w.TimeTaken() 69 - } 70 - return s 71 - } 72 - 73 - func (p Pipeline) Workflows() []string { 74 - var ws []string 75 - for v := range p.Statuses { 76 - ws = append(ws, v) 77 - } 78 - slices.Sort(ws) 79 - return ws 80 - } 81 - 82 - // if we know that a spindle has picked up this pipeline, then it is Responding 83 - func (p Pipeline) IsResponding() bool { 84 - return len(p.Statuses) != 0 85 - } 86 - 87 - type Trigger struct { 88 - Id int 89 - Kind workflow.TriggerKind 90 - 91 - // push trigger fields 92 - PushRef *string 93 - PushNewSha *string 94 - PushOldSha *string 95 - 96 - // pull request trigger fields 97 - PRSourceBranch *string 98 - PRTargetBranch *string 99 - PRSourceSha *string 100 - PRAction *string 101 - } 102 - 103 - func (t *Trigger) IsPush() bool { 104 - return t != nil && t.Kind == workflow.TriggerKindPush 105 - } 106 - 107 - func (t *Trigger) IsPullRequest() bool { 108 - return t != nil && t.Kind == workflow.TriggerKindPullRequest 109 - } 110 - 111 - func (t *Trigger) TargetRef() string { 112 - if t.IsPush() { 113 - return plumbing.ReferenceName(*t.PushRef).Short() 114 - } else if t.IsPullRequest() { 115 - return *t.PRTargetBranch 116 - } 117 - 118 - return "" 119 - } 120 - 121 - type PipelineStatus struct { 122 - ID int 123 - Spindle string 124 - Rkey string 125 - PipelineKnot string 126 - PipelineRkey string 127 - Created time.Time 128 - Workflow string 129 - Status spindle.StatusKind 130 - Error *string 131 - ExitCode int 132 - } 133 - 134 - func GetPipelines(e Execer, filters ...filter) ([]Pipeline, error) { 135 - var pipelines []Pipeline 12 + func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) { 13 + var pipelines []models.Pipeline 136 14 137 15 var conditions []string 138 16 var args []any ··· 156 34 defer rows.Close() 157 35 158 36 for rows.Next() { 159 - var pipeline Pipeline 37 + var pipeline models.Pipeline 160 38 var createdAt string 161 39 err = rows.Scan( 162 40 &pipeline.Id, ··· 185 63 return pipelines, nil 186 64 } 187 65 188 - func AddPipeline(e Execer, pipeline Pipeline) error { 66 + func AddPipeline(e Execer, pipeline models.Pipeline) error { 189 67 args := []any{ 190 68 pipeline.Rkey, 191 69 pipeline.Knot, ··· 216 94 return err 217 95 } 218 96 219 - func AddTrigger(e Execer, trigger Trigger) (int64, error) { 97 + func AddTrigger(e Execer, trigger models.Trigger) (int64, error) { 220 98 args := []any{ 221 99 trigger.Kind, 222 100 trigger.PushRef, ··· 252 130 return res.LastInsertId() 253 131 } 254 132 255 - func AddPipelineStatus(e Execer, status PipelineStatus) error { 133 + func AddPipelineStatus(e Execer, status models.PipelineStatus) error { 256 134 args := []any{ 257 135 status.Spindle, 258 136 status.Rkey, ··· 290 168 291 169 // this is a mega query, but the most useful one: 292 170 // get N pipelines, for each one get the latest status of its N workflows 293 - func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) { 171 + func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) { 294 172 var conditions []string 295 173 var args []any 296 174 for _, filter := range filters { ··· 335 213 } 336 214 defer rows.Close() 337 215 338 - pipelines := make(map[string]Pipeline) 216 + pipelines := make(map[string]models.Pipeline) 339 217 for rows.Next() { 340 - var p Pipeline 341 - var t Trigger 218 + var p models.Pipeline 219 + var t models.Trigger 342 220 var created string 343 221 344 222 err := rows.Scan( ··· 370 248 371 249 t.Id = p.TriggerId 372 250 p.Trigger = &t 373 - p.Statuses = make(map[string]WorkflowStatus) 251 + p.Statuses = make(map[string]models.WorkflowStatus) 374 252 375 253 k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey) 376 254 pipelines[k] = p ··· 409 287 defer rows.Close() 410 288 411 289 for rows.Next() { 412 - var ps PipelineStatus 290 + var ps models.PipelineStatus 413 291 var created string 414 292 415 293 err := rows.Scan( ··· 442 320 } 443 321 statuses, _ := pipeline.Statuses[ps.Workflow] 444 322 if !ok { 445 - pipeline.Statuses[ps.Workflow] = WorkflowStatus{} 323 + pipeline.Statuses[ps.Workflow] = models.WorkflowStatus{} 446 324 } 447 325 448 326 // append ··· 453 331 pipelines[key] = pipeline 454 332 } 455 333 456 - var all []Pipeline 334 + var all []models.Pipeline 457 335 for _, p := range pipelines { 458 336 for _, s := range p.Statuses { 459 - slices.SortFunc(s.Data, func(a, b PipelineStatus) int { 337 + slices.SortFunc(s.Data, func(a, b models.PipelineStatus) int { 460 338 if a.Created.After(b.Created) { 461 339 return 1 462 340 } ··· 476 354 } 477 355 478 356 // sort pipelines by date 479 - slices.SortFunc(all, func(a, b Pipeline) int { 357 + slices.SortFunc(all, func(a, b models.Pipeline) int { 480 358 if a.Created.After(b.Created) { 481 359 return -1 482 360 }
+130
appview/models/pipeline.go
··· 1 + package models 2 + 3 + import ( 4 + "slices" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/go-git/go-git/v5/plumbing" 9 + spindle "tangled.org/core/spindle/models" 10 + "tangled.org/core/workflow" 11 + ) 12 + 13 + type Pipeline struct { 14 + Id int 15 + Rkey string 16 + Knot string 17 + RepoOwner syntax.DID 18 + RepoName string 19 + TriggerId int 20 + Sha string 21 + Created time.Time 22 + 23 + // populate when querying for reverse mappings 24 + Trigger *Trigger 25 + Statuses map[string]WorkflowStatus 26 + } 27 + 28 + type WorkflowStatus struct { 29 + Data []PipelineStatus 30 + } 31 + 32 + func (w WorkflowStatus) Latest() PipelineStatus { 33 + return w.Data[len(w.Data)-1] 34 + } 35 + 36 + // time taken by this workflow to reach an "end state" 37 + func (w WorkflowStatus) TimeTaken() time.Duration { 38 + var start, end *time.Time 39 + for _, s := range w.Data { 40 + if s.Status.IsStart() { 41 + start = &s.Created 42 + } 43 + if s.Status.IsFinish() { 44 + end = &s.Created 45 + } 46 + } 47 + 48 + if start != nil && end != nil && end.After(*start) { 49 + return end.Sub(*start) 50 + } 51 + 52 + return 0 53 + } 54 + 55 + func (p Pipeline) Counts() map[string]int { 56 + m := make(map[string]int) 57 + for _, w := range p.Statuses { 58 + m[w.Latest().Status.String()] += 1 59 + } 60 + return m 61 + } 62 + 63 + func (p Pipeline) TimeTaken() time.Duration { 64 + var s time.Duration 65 + for _, w := range p.Statuses { 66 + s += w.TimeTaken() 67 + } 68 + return s 69 + } 70 + 71 + func (p Pipeline) Workflows() []string { 72 + var ws []string 73 + for v := range p.Statuses { 74 + ws = append(ws, v) 75 + } 76 + slices.Sort(ws) 77 + return ws 78 + } 79 + 80 + // if we know that a spindle has picked up this pipeline, then it is Responding 81 + func (p Pipeline) IsResponding() bool { 82 + return len(p.Statuses) != 0 83 + } 84 + 85 + type Trigger struct { 86 + Id int 87 + Kind workflow.TriggerKind 88 + 89 + // push trigger fields 90 + PushRef *string 91 + PushNewSha *string 92 + PushOldSha *string 93 + 94 + // pull request trigger fields 95 + PRSourceBranch *string 96 + PRTargetBranch *string 97 + PRSourceSha *string 98 + PRAction *string 99 + } 100 + 101 + func (t *Trigger) IsPush() bool { 102 + return t != nil && t.Kind == workflow.TriggerKindPush 103 + } 104 + 105 + func (t *Trigger) IsPullRequest() bool { 106 + return t != nil && t.Kind == workflow.TriggerKindPullRequest 107 + } 108 + 109 + func (t *Trigger) TargetRef() string { 110 + if t.IsPush() { 111 + return plumbing.ReferenceName(*t.PushRef).Short() 112 + } else if t.IsPullRequest() { 113 + return *t.PRTargetBranch 114 + } 115 + 116 + return "" 117 + } 118 + 119 + type PipelineStatus struct { 120 + ID int 121 + Spindle string 122 + Rkey string 123 + PipelineKnot string 124 + PipelineRkey string 125 + Created time.Time 126 + Workflow string 127 + Status spindle.StatusKind 128 + Error *string 129 + ExitCode int 130 + }
+25
appview/models/pubkey.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "time" 6 + ) 7 + 8 + type PublicKey struct { 9 + Did string `json:"did"` 10 + Key string `json:"key"` 11 + Name string `json:"name"` 12 + Rkey string `json:"rkey"` 13 + Created *time.Time 14 + } 15 + 16 + func (p PublicKey) MarshalJSON() ([]byte, error) { 17 + type Alias PublicKey 18 + return json.Marshal(&struct { 19 + Created string `json:"created"` 20 + *Alias 21 + }{ 22 + Created: p.Created.Format(time.RFC3339), 23 + Alias: (*Alias)(&p), 24 + }) 25 + }
+14
appview/models/punchcard.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type Punch struct { 6 + Did string 7 + Date time.Time 8 + Count int 9 + } 10 + 11 + type Punchcard struct { 12 + Total int 13 + Punches []Punch 14 + }
+44
appview/models/registration.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + // Registration represents a knot registration. Knot would've been a better 6 + // name but we're stuck with this for historical reasons. 7 + type Registration struct { 8 + Id int64 9 + Domain string 10 + ByDid string 11 + Created *time.Time 12 + Registered *time.Time 13 + NeedsUpgrade bool 14 + } 15 + 16 + func (r *Registration) Status() Status { 17 + if r.NeedsUpgrade { 18 + return NeedsUpgrade 19 + } else if r.Registered != nil { 20 + return Registered 21 + } else { 22 + return Pending 23 + } 24 + } 25 + 26 + func (r *Registration) IsRegistered() bool { 27 + return r.Status() == Registered 28 + } 29 + 30 + func (r *Registration) IsNeedsUpgrade() bool { 31 + return r.Status() == NeedsUpgrade 32 + } 33 + 34 + func (r *Registration) IsPending() bool { 35 + return r.Status() == Pending 36 + } 37 + 38 + type Status uint32 39 + 40 + const ( 41 + Registered Status = iota 42 + Pending 43 + NeedsUpgrade 44 + )
+10
appview/models/signup.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type InflightSignup struct { 6 + Id int64 7 + Email string 8 + InviteCode string 9 + Created time.Time 10 + }
+25
appview/models/spindle.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Spindle struct { 10 + Id int 11 + Owner syntax.DID 12 + Instance string 13 + Verified *time.Time 14 + Created time.Time 15 + NeedsUpgrade bool 16 + } 17 + 18 + type SpindleMember struct { 19 + Id int 20 + Did syntax.DID // owner of the record 21 + Rkey string // rkey of the record 22 + Instance string 23 + Subject syntax.DID // the member being added 24 + Created time.Time 25 + }
+17
appview/models/star.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Star struct { 10 + StarredByDid string 11 + RepoAt syntax.ATURI 12 + Created time.Time 13 + Rkey string 14 + 15 + // optionally, populate this when querying for reverse mappings 16 + Repo *Repo 17 + }
+95
appview/models/string.go
··· 1 + package models 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "io" 7 + "strings" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.org/core/api/tangled" 12 + ) 13 + 14 + type String struct { 15 + Did syntax.DID 16 + Rkey string 17 + 18 + Filename string 19 + Description string 20 + Contents string 21 + Created time.Time 22 + Edited *time.Time 23 + } 24 + 25 + func (s *String) StringAt() syntax.ATURI { 26 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 27 + } 28 + 29 + func (s *String) AsRecord() tangled.String { 30 + return tangled.String{ 31 + Filename: s.Filename, 32 + Description: s.Description, 33 + Contents: s.Contents, 34 + CreatedAt: s.Created.Format(time.RFC3339), 35 + } 36 + } 37 + 38 + func StringFromRecord(did, rkey string, record tangled.String) String { 39 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 40 + if err != nil { 41 + created = time.Now() 42 + } 43 + return String{ 44 + Did: syntax.DID(did), 45 + Rkey: rkey, 46 + Filename: record.Filename, 47 + Description: record.Description, 48 + Contents: record.Contents, 49 + Created: created, 50 + } 51 + } 52 + 53 + type StringStats struct { 54 + LineCount uint64 55 + ByteCount uint64 56 + } 57 + 58 + func (s String) Stats() StringStats { 59 + lineCount, err := countLines(strings.NewReader(s.Contents)) 60 + if err != nil { 61 + // non-fatal 62 + // TODO: log this? 63 + } 64 + 65 + return StringStats{ 66 + LineCount: uint64(lineCount), 67 + ByteCount: uint64(len(s.Contents)), 68 + } 69 + } 70 + 71 + func countLines(r io.Reader) (int, error) { 72 + buf := make([]byte, 32*1024) 73 + bufLen := 0 74 + count := 0 75 + nl := []byte{'\n'} 76 + 77 + for { 78 + c, err := r.Read(buf) 79 + if c > 0 { 80 + bufLen += c 81 + } 82 + count += bytes.Count(buf[:c], nl) 83 + 84 + switch { 85 + case err == io.EOF: 86 + /* handle last line not having a newline at the end */ 87 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 88 + count++ 89 + } 90 + return count, nil 91 + case err != nil: 92 + return 0, err 93 + } 94 + } 95 + }
+27
appview/validator/string.go
··· 1 + package validator 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "unicode/utf8" 7 + 8 + "tangled.org/core/appview/models" 9 + ) 10 + 11 + func (v *Validator) ValidateString(s *models.String) error { 12 + var err error 13 + 14 + if utf8.RuneCountInString(s.Filename) > 140 { 15 + err = errors.Join(err, fmt.Errorf("filename too long")) 16 + } 17 + 18 + if utf8.RuneCountInString(s.Description) > 280 { 19 + err = errors.Join(err, fmt.Errorf("description too long")) 20 + } 21 + 22 + if len(s.Contents) == 0 { 23 + err = errors.Join(err, fmt.Errorf("contents is empty")) 24 + } 25 + 26 + return err 27 + }
+23
appview/models/timeline.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type TimelineEvent struct { 6 + *Repo 7 + *Follow 8 + *Star 9 + 10 + EventAt time.Time 11 + 12 + // optional: populate only if Repo is a fork 13 + Source *Repo 14 + 15 + // optional: populate only if event is Follow 16 + *Profile 17 + *FollowStats 18 + *FollowStatus 19 + 20 + // optional: populate only if event is Repo 21 + IsStarred bool 22 + StarCount int64 23 + }
+1 -1
appview/db/label.go
··· 349 349 defs[l.AtUri().String()] = &l 350 350 } 351 351 352 - return &models.LabelApplicationCtx{defs}, nil 352 + return &models.LabelApplicationCtx{Defs: defs}, nil 353 353 }
+1 -1
appview/reporesolver/resolver.go
··· 212 212 func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 213 213 if u != nil { 214 214 r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 215 - return repoinfo.RolesInRepo{r} 215 + return repoinfo.RolesInRepo{Roles: r} 216 216 } else { 217 217 return repoinfo.RolesInRepo{} 218 218 }
+36 -6
appview/pages/templates/repo/settings/general.html
··· 46 46 47 47 {{ define "defaultLabelSettings" }} 48 48 <div class="flex flex-col gap-2"> 49 - <h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2> 50 - <p class="text-gray-500 dark:text-gray-400"> 51 - Manage your issues and pulls by creating labels to categorize them. Only 52 - repository owners may configure labels. You may choose to subscribe to 53 - default labels, or create entirely custom labels. 54 - </p> 49 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 50 + <div class="col-span-1 md:col-span-2"> 51 + <h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2> 52 + <p class="text-gray-500 dark:text-gray-400"> 53 + Manage your issues and pulls by creating labels to categorize them. Only 54 + repository owners may configure labels. You may choose to subscribe to 55 + default labels, or create entirely custom labels. 56 + <p> 57 + </div> 58 + <form class="col-span-1 md:col-span-1 md:justify-self-end"> 59 + {{ $title := "Unubscribe from all labels" }} 60 + {{ $icon := "x" }} 61 + {{ $text := "unsubscribe all" }} 62 + {{ $action := "unsubscribe" }} 63 + {{ if $.ShouldSubscribeAll }} 64 + {{ $title = "Subscribe to all labels" }} 65 + {{ $icon = "check-check" }} 66 + {{ $text = "subscribe all" }} 67 + {{ $action = "subscribe" }} 68 + {{ end }} 69 + {{ range .DefaultLabels }} 70 + <input type="hidden" name="label" value="{{ .AtUri.String }}"> 71 + {{ end }} 72 + <button 73 + type="submit" 74 + title="{{$title}}" 75 + class="btn flex items-center gap-2 group" 76 + hx-swap="none" 77 + hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}" 78 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}> 79 + {{ i $icon "size-4" }} 80 + {{ $text }} 81 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 + </button> 83 + </form> 84 + </div> 55 85 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 56 86 {{ range .DefaultLabels }} 57 87 <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+10 -33
appview/pages/templates/timeline/fragments/timeline.html
··· 82 82 {{ $event := index . 1 }} 83 83 {{ $follow := $event.Follow }} 84 84 {{ $profile := $event.Profile }} 85 - {{ $stat := $event.FollowStats }} 85 + {{ $followStats := $event.FollowStats }} 86 + {{ $followStatus := $event.FollowStatus }} 86 87 87 88 {{ $userHandle := resolve $follow.UserDid }} 88 89 {{ $subjectHandle := resolve $follow.SubjectDid }} ··· 92 93 {{ template "user/fragments/picHandleLink" $subjectHandle }} 93 94 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 94 95 </div> 95 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4"> 96 - <div class="flex items-center gap-4 flex-1"> 97 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 98 - <img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 99 - </div> 100 - 101 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 102 - <a href="/{{ $subjectHandle }}"> 103 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 104 - </a> 105 - {{ with $profile }} 106 - {{ with .Description }} 107 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 108 - {{ end }} 109 - {{ end }} 110 - {{ with $stat }} 111 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 112 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 113 - <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 114 - <span class="select-none after:content-['·']"></span> 115 - <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 116 - </div> 117 - {{ end }} 118 - </div> 119 - </div> 120 - 121 - {{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }} 122 - <div class="flex-shrink-0 w-fit ml-auto"> 123 - {{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }} 124 - </div> 125 - {{ end }} 126 - </div> 96 + {{ template "user/fragments/followCard" 97 + (dict 98 + "LoggedInUser" $root.LoggedInUser 99 + "UserDid" $follow.SubjectDid 100 + "Profile" $profile 101 + "FollowStatus" $followStatus 102 + "FollowersCount" $followStats.Followers 103 + "FollowingCount" $followStats.Following) }} 127 104 {{ end }}
+8 -1
appview/pages/templates/user/followers.html
··· 10 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 11 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 12 {{ range .Followers }} 13 - {{ template "user/fragments/followCard" . }} 13 + {{ template "user/fragments/followCard" 14 + (dict 15 + "LoggedInUser" $.LoggedInUser 16 + "UserDid" .UserDid 17 + "Profile" .Profile 18 + "FollowStatus" .FollowStatus 19 + "FollowersCount" .FollowersCount 20 + "FollowingCount" .FollowingCount) }} 14 21 {{ else }} 15 22 <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 16 23 {{ end }}
+8 -1
appview/pages/templates/user/following.html
··· 10 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 11 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 12 {{ range .Following }} 13 - {{ template "user/fragments/followCard" . }} 13 + {{ template "user/fragments/followCard" 14 + (dict 15 + "LoggedInUser" $.LoggedInUser 16 + "UserDid" .UserDid 17 + "Profile" .Profile 18 + "FollowStatus" .FollowStatus 19 + "FollowersCount" .FollowersCount 20 + "FollowingCount" .FollowingCount) }} 14 21 {{ else }} 15 22 <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 16 23 {{ end }}
+6 -2
appview/pages/templates/user/fragments/follow.html
··· 1 1 {{ define "user/fragments/follow" }} 2 2 <button id="{{ normalizeForHtmlId .UserDid }}" 3 - class="btn mt-2 flex gap-2 items-center group" 3 + class="btn w-full flex gap-2 items-center group" 4 4 5 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 6 hx-post="/follow?subject={{.UserDid}}" ··· 12 12 hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 13 hx-swap="outerHTML" 14 14 > 15 - {{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }} 15 + {{ if eq .FollowStatus.String "IsNotFollowing" }} 16 + {{ i "user-round-plus" "w-4 h-4" }} follow 17 + {{ else }} 18 + {{ i "user-round-minus" "w-4 h-4" }} unfollow 19 + {{ end }} 16 20 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 17 21 </button> 18 22 {{ end }}
+58 -3
appview/posthog/notifier.go appview/notify/posthog/notifier.go
··· 1 - package posthog_service 1 + package posthog 2 2 3 3 import ( 4 4 "context" ··· 98 98 } 99 99 } 100 100 101 + func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 102 + err := n.client.Enqueue(posthog.Capture{ 103 + DistinctId: pull.OwnerDid, 104 + Event: "pull_closed", 105 + Properties: posthog.Properties{ 106 + "repo_at": pull.RepoAt, 107 + "pull_id": pull.PullId, 108 + }, 109 + }) 110 + if err != nil { 111 + log.Println("failed to enqueue posthog event:", err) 112 + } 113 + } 114 + 101 115 func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 102 116 err := n.client.Enqueue(posthog.Capture{ 103 117 DistinctId: follow.UserDid, ··· 152 166 } 153 167 } 154 168 155 - func (n *posthogNotifier) CreateString(ctx context.Context, string models.String) { 169 + func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) { 156 170 err := n.client.Enqueue(posthog.Capture{ 157 171 DistinctId: string.Did.String(), 158 - Event: "create_string", 172 + Event: "new_string", 159 173 Properties: posthog.Properties{"rkey": string.Rkey}, 160 174 }) 161 175 if err != nil { 162 176 log.Println("failed to enqueue posthog event:", err) 163 177 } 164 178 } 179 + 180 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 181 + err := n.client.Enqueue(posthog.Capture{ 182 + DistinctId: comment.Did, 183 + Event: "new_issue_comment", 184 + Properties: posthog.Properties{ 185 + "issue_at": comment.IssueAt, 186 + }, 187 + }) 188 + if err != nil { 189 + log.Println("failed to enqueue posthog event:", err) 190 + } 191 + } 192 + 193 + func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 194 + err := n.client.Enqueue(posthog.Capture{ 195 + DistinctId: issue.Did, 196 + Event: "issue_closed", 197 + Properties: posthog.Properties{ 198 + "repo_at": issue.RepoAt.String(), 199 + "issue_id": issue.IssueId, 200 + }, 201 + }) 202 + if err != nil { 203 + log.Println("failed to enqueue posthog event:", err) 204 + } 205 + } 206 + 207 + func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 208 + err := n.client.Enqueue(posthog.Capture{ 209 + DistinctId: pull.OwnerDid, 210 + Event: "pull_merged", 211 + Properties: posthog.Properties{ 212 + "repo_at": pull.RepoAt, 213 + "pull_id": pull.PullId, 214 + }, 215 + }) 216 + if err != nil { 217 + log.Println("failed to enqueue posthog event:", err) 218 + } 219 + }
+36 -6
appview/db/repos.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.org/core/api/tangled" 13 15 "tangled.org/core/appview/models" 14 16 ) 15 17 18 + type Repo struct { 19 + Id int64 20 + Did string 21 + Name string 22 + Knot string 23 + Rkey string 24 + Created time.Time 25 + Description string 26 + Spindle string 27 + 28 + // optionally, populate this when querying for reverse mappings 29 + RepoStats *models.RepoStats 30 + 31 + // optional 32 + Source string 33 + } 34 + 35 + func (r Repo) RepoAt() syntax.ATURI { 36 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 37 + } 38 + 39 + func (r Repo) DidSlashRepo() string { 40 + p, _ := securejoin.SecureJoin(r.Did, r.Name) 41 + return p 42 + } 43 + 16 44 func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 17 45 repoMap := make(map[syntax.ATURI]*models.Repo) 18 46 ··· 35 63 36 64 repoQuery := fmt.Sprintf( 37 65 `select 66 + id, 38 67 did, 39 68 name, 40 69 knot, ··· 63 92 var description, source, spindle sql.NullString 64 93 65 94 err := rows.Scan( 95 + &repo.Id, 66 96 &repo.Did, 67 97 &repo.Name, 68 98 &repo.Knot, ··· 327 357 var repo models.Repo 328 358 var nullableDescription sql.NullString 329 359 330 - row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 360 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 331 361 332 362 var createdAt string 333 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 363 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 334 364 return nil, err 335 365 } 336 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 386 416 var repos []models.Repo 387 417 388 418 rows, err := e.Query( 389 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 419 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 390 420 from repos r 391 421 left join collaborators c on r.at_uri = c.repo_at 392 422 where (r.did = ? or c.subject_did = ?) ··· 406 436 var nullableDescription sql.NullString 407 437 var nullableSource sql.NullString 408 438 409 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 439 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 410 440 if err != nil { 411 441 return nil, err 412 442 } ··· 443 473 var nullableSource sql.NullString 444 474 445 475 row := e.QueryRow( 446 - `select did, name, knot, rkey, description, created, source 476 + `select id, did, name, knot, rkey, description, created, source 447 477 from repos 448 478 where did = ? and name = ? and source is not null and source != ''`, 449 479 did, name, 450 480 ) 451 481 452 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 482 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 453 483 if err != nil { 454 484 return nil, err 455 485 }
+12
appview/notify/merged_notifier.go
··· 72 72 } 73 73 } 74 74 75 + func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 76 + for _, notifier := range m.notifiers { 77 + notifier.NewPullMerged(ctx, pull) 78 + } 79 + } 80 + 81 + func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 82 + for _, notifier := range m.notifiers { 83 + notifier.NewPullClosed(ctx, pull) 84 + } 85 + } 86 + 75 87 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 76 88 for _, notifier := range m.notifiers { 77 89 notifier.UpdateProfile(ctx, profile)
+4 -11
appview/pages/templates/errors/500.html
··· 5 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 6 <div class="mb-6"> 7 7 <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 - {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 8 + {{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 9 </div> 10 10 </div> 11 11 ··· 14 14 500 &mdash; internal server error 15 15 </h1> 16 16 <p class="text-gray-600 dark:text-gray-300"> 17 - Something went wrong on our end. We've been notified and are working to fix the issue. 18 - </p> 19 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 - <div class="flex items-center gap-2"> 21 - {{ i "info" "w-4 h-4" }} 22 - <span class="font-medium">we're on it!</span> 23 - </div> 24 - <p class="mt-1">Our team has been automatically notified about this error.</p> 25 - </div> 17 + We encountered an error while processing your request. Please try again later. 18 + </p> 26 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 20 <button onclick="location.reload()" class="btn-create gap-2"> 28 21 {{ i "refresh-cw" "w-4 h-4" }} 29 22 try again 30 23 </button> 31 24 <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 - {{ i "home" "w-4 h-4" }} 25 + {{ i "arrow-left" "w-4 h-4" }} 33 26 back to home 34 27 </a> 35 28 </div>
+173
appview/pages/templates/user/settings/notifications.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "notificationSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "notificationSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Choose which notifications you want to receive when activity happens on your repositories and profile. 25 + </p> 26 + </div> 27 + </div> 28 + 29 + <form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6"> 30 + 31 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 + <div class="flex items-center justify-between p-2"> 33 + <div class="flex items-center gap-2"> 34 + <div class="flex flex-col gap-1"> 35 + <span class="font-bold">Repository starred</span> 36 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 37 + <span>When someone stars your repository.</span> 38 + </div> 39 + </div> 40 + </div> 41 + <label class="flex items-center gap-2"> 42 + <input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}> 43 + </label> 44 + </div> 45 + 46 + <div class="flex items-center justify-between p-2"> 47 + <div class="flex items-center gap-2"> 48 + <div class="flex flex-col gap-1"> 49 + <span class="font-bold">New issues</span> 50 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 51 + <span>When someone creates an issue on your repository.</span> 52 + </div> 53 + </div> 54 + </div> 55 + <label class="flex items-center gap-2"> 56 + <input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}> 57 + </label> 58 + </div> 59 + 60 + <div class="flex items-center justify-between p-2"> 61 + <div class="flex items-center gap-2"> 62 + <div class="flex flex-col gap-1"> 63 + <span class="font-bold">Issue comments</span> 64 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 65 + <span>When someone comments on an issue you're involved with.</span> 66 + </div> 67 + </div> 68 + </div> 69 + <label class="flex items-center gap-2"> 70 + <input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}> 71 + </label> 72 + </div> 73 + 74 + <div class="flex items-center justify-between p-2"> 75 + <div class="flex items-center gap-2"> 76 + <div class="flex flex-col gap-1"> 77 + <span class="font-bold">Issue closed</span> 78 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 79 + <span>When an issue on your repository is closed.</span> 80 + </div> 81 + </div> 82 + </div> 83 + <label class="flex items-center gap-2"> 84 + <input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}> 85 + </label> 86 + </div> 87 + 88 + <div class="flex items-center justify-between p-2"> 89 + <div class="flex items-center gap-2"> 90 + <div class="flex flex-col gap-1"> 91 + <span class="font-bold">New pull requests</span> 92 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 93 + <span>When someone creates a pull request on your repository.</span> 94 + </div> 95 + </div> 96 + </div> 97 + <label class="flex items-center gap-2"> 98 + <input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}> 99 + </label> 100 + </div> 101 + 102 + <div class="flex items-center justify-between p-2"> 103 + <div class="flex items-center gap-2"> 104 + <div class="flex flex-col gap-1"> 105 + <span class="font-bold">Pull request comments</span> 106 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 107 + <span>When someone comments on a pull request you're involved with.</span> 108 + </div> 109 + </div> 110 + </div> 111 + <label class="flex items-center gap-2"> 112 + <input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}> 113 + </label> 114 + </div> 115 + 116 + <div class="flex items-center justify-between p-2"> 117 + <div class="flex items-center gap-2"> 118 + <div class="flex flex-col gap-1"> 119 + <span class="font-bold">Pull request merged</span> 120 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 121 + <span>When your pull request is merged.</span> 122 + </div> 123 + </div> 124 + </div> 125 + <label class="flex items-center gap-2"> 126 + <input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}> 127 + </label> 128 + </div> 129 + 130 + <div class="flex items-center justify-between p-2"> 131 + <div class="flex items-center gap-2"> 132 + <div class="flex flex-col gap-1"> 133 + <span class="font-bold">New followers</span> 134 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 135 + <span>When someone follows you.</span> 136 + </div> 137 + </div> 138 + </div> 139 + <label class="flex items-center gap-2"> 140 + <input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}> 141 + </label> 142 + </div> 143 + 144 + <div class="flex items-center justify-between p-2"> 145 + <div class="flex items-center gap-2"> 146 + <div class="flex flex-col gap-1"> 147 + <span class="font-bold">Email notifications</span> 148 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 + <span>Receive notifications via email in addition to in-app notifications.</span> 150 + </div> 151 + </div> 152 + </div> 153 + <label class="flex items-center gap-2"> 154 + <input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}> 155 + </label> 156 + </div> 157 + </div> 158 + 159 + <div class="flex justify-end pt-2"> 160 + <button 161 + type="submit" 162 + class="btn-create flex items-center gap-2 group" 163 + > 164 + {{ i "save" "w-4 h-4" }} 165 + save 166 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 167 + </button> 168 + </div> 169 + <div id="settings-notifications-success"></div> 170 + 171 + <div id="settings-notifications-error" class="error"></div> 172 + </form> 173 + {{ end }}
+2 -2
appview/pages/templates/strings/put.html
··· 3 3 {{ define "content" }} 4 4 <div class="px-6 py-2 mb-4"> 5 5 {{ if eq .Action "new" }} 6 - <p class="text-xl font-bold dark:text-white">Create a new string</p> 7 - <p class="">Store and share code snippets with ease.</p> 6 + <p class="text-xl font-bold dark:text-white mb-1">Create a new string</p> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p> 8 8 {{ else }} 9 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 10 {{ end }}
+4 -1
appview/validator/validator.go
··· 4 4 "tangled.org/core/appview/db" 5 5 "tangled.org/core/appview/pages/markup" 6 6 "tangled.org/core/idresolver" 7 + "tangled.org/core/rbac" 7 8 ) 8 9 9 10 type Validator struct { 10 11 db *db.DB 11 12 sanitizer markup.Sanitizer 12 13 resolver *idresolver.Resolver 14 + enforcer *rbac.Enforcer 13 15 } 14 16 15 - func New(db *db.DB, res *idresolver.Resolver) *Validator { 17 + func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 16 18 return &Validator{ 17 19 db: db, 18 20 sanitizer: markup.NewSanitizer(), 19 21 resolver: res, 22 + enforcer: enforcer, 20 23 } 21 24 }
+1 -1
knotserver/xrpc/repo_blob.go
··· 44 44 45 45 contents, err := gr.RawContent(treePath) 46 46 if err != nil { 47 - x.Logger.Error("file content", "error", err.Error()) 47 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 48 48 writeError(w, xrpcerr.NewXrpcError( 49 49 xrpcerr.WithTag("FileNotFound"), 50 50 xrpcerr.WithMessage("file not found at the specified path"),
+6
appview/pages/templates/repo/tree.html
··· 88 88 </div> 89 89 </main> 90 90 {{end}} 91 + 92 + {{ define "repoAfter" }} 93 + {{- if or .HTMLReadme .Readme -}} 94 + {{ template "repo/fragments/readme" . }} 95 + {{- end -}} 96 + {{ end }}
+224
appview/pages/templates/brand/brand.html
··· 1 + {{ define "title" }}brand{{ end }} 2 + 3 + {{ define "content" }} 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> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="space-y-16"> 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> 25 + 26 + <!-- Black Logotype Section --> 27 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 28 + <div class="order-2 lg:order-1"> 29 + <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 30 + <img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg" 31 + alt="Tangled logo - black version" 32 + class="w-full max-w-sm mx-auto" /> 33 + </div> 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. 41 + </p> 42 + </div> 43 + </section> 44 + 45 + <!-- White Logotype Section --> 46 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 47 + <div class="order-2 lg:order-1"> 48 + <div class="bg-black p-8 sm:p-16 rounded"> 49 + <img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg" 50 + alt="Tangled logo - white version" 51 + class="w-full max-w-sm mx-auto" /> 52 + </div> 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. 60 + </p> 61 + </div> 62 + </section> 63 + 64 + <!-- Mark Only Section --> 65 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 66 + <div class="order-2 lg:order-1"> 67 + <div class="grid grid-cols-2 gap-2"> 68 + <!-- Black mark on light background --> 69 + <div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded"> 70 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 71 + alt="Dolly face - black version" 72 + class="w-full max-w-16 mx-auto" /> 73 + </div> 74 + <!-- White mark on dark background --> 75 + <div class="bg-black p-8 sm:p-12 rounded"> 76 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 77 + alt="Dolly face - white version" 78 + class="w-full max-w-16 mx-auto" /> 79 + </div> 80 + </div> 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"> 88 + <strong class="font-semibold">Note</strong>: for situations where the background 89 + is unknown, use the black version for ideal contrast in most environments. 90 + </p> 91 + </div> 92 + </section> 93 + 94 + <!-- Colored Backgrounds Section --> 95 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 96 + <div class="order-2 lg:order-1"> 97 + <div class="grid grid-cols-2 gap-2"> 98 + <!-- Pastel Green background --> 99 + <div class="bg-green-500 p-8 sm:p-12 rounded"> 100 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 101 + alt="Tangled logo on pastel green background" 102 + class="w-full max-w-16 mx-auto" /> 103 + </div> 104 + <!-- Pastel Blue background --> 105 + <div class="bg-blue-500 p-8 sm:p-12 rounded"> 106 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 107 + alt="Tangled logo on pastel blue background" 108 + class="w-full max-w-16 mx-auto" /> 109 + </div> 110 + <!-- Pastel Yellow background --> 111 + <div class="bg-yellow-500 p-8 sm:p-12 rounded"> 112 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 113 + alt="Tangled logo on pastel yellow background" 114 + class="w-full max-w-16 mx-auto" /> 115 + </div> 116 + <!-- Pastel Red background --> 117 + <div class="bg-red-500 p-8 sm:p-12 rounded"> 118 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 119 + alt="Tangled logo on pastel red background" 120 + class="w-full max-w-16 mx-auto" /> 121 + </div> 122 + </div> 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"> 130 + The white logo mark provides contrast on colored backgrounds. 131 + Perfect for more fun design contexts. 132 + </p> 133 + </div> 134 + </section> 135 + 136 + <!-- Black Logo on Pastel Backgrounds Section --> 137 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 138 + <div class="order-2 lg:order-1"> 139 + <div class="grid grid-cols-2 gap-2"> 140 + <!-- Pastel Green background --> 141 + <div class="bg-green-200 p-8 sm:p-12 rounded"> 142 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 143 + alt="Tangled logo on pastel green background" 144 + class="w-full max-w-16 mx-auto" /> 145 + </div> 146 + <!-- Pastel Blue background --> 147 + <div class="bg-blue-200 p-8 sm:p-12 rounded"> 148 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 149 + alt="Tangled logo on pastel blue background" 150 + class="w-full max-w-16 mx-auto" /> 151 + </div> 152 + <!-- Pastel Yellow background --> 153 + <div class="bg-yellow-200 p-8 sm:p-12 rounded"> 154 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 155 + alt="Tangled logo on pastel yellow background" 156 + class="w-full max-w-16 mx-auto" /> 157 + </div> 158 + <!-- Pastel Pink background --> 159 + <div class="bg-pink-200 p-8 sm:p-12 rounded"> 160 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 161 + alt="Tangled logo on pastel pink background" 162 + class="w-full max-w-16 mx-auto" /> 163 + </div> 164 + </div> 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"> 172 + The dark logo mark works beautifully on pastel backgrounds, 173 + providing crisp contrast. 174 + </p> 175 + </div> 176 + </section> 177 + 178 + <!-- Recoloring Section --> 179 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 180 + <div class="order-2 lg:order-1"> 181 + <div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded"> 182 + <img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg" 183 + alt="Recolored Tangled logotype in gray/sand color" 184 + class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" /> 185 + </div> 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"> 193 + Recoloring the logotype is allowed as long as readability is maintained. 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> 220 + 221 + </div> 222 + </main> 223 + </div> 224 + {{ end }}
+1 -1
appview/pages/templates/user/fragments/followCard.html
··· 1 1 {{ define "user/fragments/followCard" }} 2 2 {{ $userIdent := resolve .UserDid }} 3 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
+4 -2
appview/config/config.go
··· 72 72 } 73 73 74 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 75 + ApiToken string `env:"API_TOKEN"` 76 + ZoneId string `env:"ZONE_ID"` 77 + TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` 78 + TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 77 79 } 78 80 79 81 func (cfg RedisConfig) ToURL() string {
+13 -9
appview/db/email.go
··· 71 71 return did, nil 72 72 } 73 73 74 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 75 - if len(ems) == 0 { 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 76 76 return make(map[string]string), nil 77 77 } 78 78 ··· 81 81 verifiedFilter = 1 82 82 } 83 83 84 + assoc := make(map[string]string) 85 + 84 86 // Create placeholders for the IN clause 85 - placeholders := make([]string, len(ems)) 86 - args := make([]any, len(ems)+1) 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 87 89 88 90 args[0] = verifiedFilter 89 - for i, em := range ems { 90 - placeholders[i] = "?" 91 - args[i+1] = em 91 + for _, email := range emails { 92 + if strings.HasPrefix(email, "did:") { 93 + assoc[email] = email 94 + continue 95 + } 96 + placeholders = append(placeholders, "?") 97 + args = append(args, email) 92 98 } 93 99 94 100 query := ` ··· 105 111 } 106 112 defer rows.Close() 107 113 108 - assoc := make(map[string]string) 109 - 110 114 for rows.Next() { 111 115 var email, did string 112 116 if err := rows.Scan(&email, &did); err != nil {
+8 -48
appview/notify/db/db.go
··· 30 30 31 31 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 32 32 var err error 33 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(star.RepoAt))) 33 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 34 34 if err != nil { 35 35 log.Printf("NewStar: failed to get repos: %v", err) 36 36 return 37 37 } 38 - if len(repos) == 0 { 39 - log.Printf("NewStar: no repo found for %s", star.RepoAt) 40 - return 41 - } 42 - repo := repos[0] 43 38 44 39 // don't notify yourself 45 40 if repo.Did == star.StarredByDid { ··· 76 71 } 77 72 78 73 func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 79 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt))) 74 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 80 75 if err != nil { 81 76 log.Printf("NewIssue: failed to get repos: %v", err) 82 77 return 83 78 } 84 - if len(repos) == 0 { 85 - log.Printf("NewIssue: no repo found for %s", issue.RepoAt) 86 - return 87 - } 88 - repo := repos[0] 89 79 90 80 if repo.Did == issue.Did { 91 81 return ··· 129 119 } 130 120 issue := issues[0] 131 121 132 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt))) 122 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 133 123 if err != nil { 134 124 log.Printf("NewIssueComment: failed to get repos: %v", err) 135 125 return 136 126 } 137 - if len(repos) == 0 { 138 - log.Printf("NewIssueComment: no repo found for %s", issue.RepoAt) 139 - return 140 - } 141 - repo := repos[0] 142 127 143 128 recipients := make(map[string]bool) 144 129 ··· 211 196 } 212 197 213 198 func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 214 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt))) 199 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 215 200 if err != nil { 216 201 log.Printf("NewPull: failed to get repos: %v", err) 217 202 return 218 203 } 219 - if len(repos) == 0 { 220 - log.Printf("NewPull: no repo found for %s", pull.RepoAt) 221 - return 222 - } 223 - repo := repos[0] 224 204 225 205 if repo.Did == pull.OwnerDid { 226 206 return ··· 266 246 } 267 247 pull := pulls[0] 268 248 269 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", comment.RepoAt)) 249 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 270 250 if err != nil { 271 251 log.Printf("NewPullComment: failed to get repos: %v", err) 272 252 return 273 253 } 274 - if len(repos) == 0 { 275 - log.Printf("NewPullComment: no repo found for %s", comment.RepoAt) 276 - return 277 - } 278 - repo := repos[0] 279 254 280 255 recipients := make(map[string]bool) 281 256 ··· 335 310 336 311 func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 337 312 // Get repo details 338 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt))) 313 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 339 314 if err != nil { 340 315 log.Printf("NewIssueClosed: failed to get repos: %v", err) 341 316 return 342 317 } 343 - if len(repos) == 0 { 344 - log.Printf("NewIssueClosed: no repo found for %s", issue.RepoAt) 345 - return 346 - } 347 - repo := repos[0] 348 318 349 319 // Don't notify yourself 350 320 if repo.Did == issue.Did { ··· 380 350 381 351 func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 382 352 // Get repo details 383 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt))) 353 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 384 354 if err != nil { 385 355 log.Printf("NewPullMerged: failed to get repos: %v", err) 386 356 return 387 357 } 388 - if len(repos) == 0 { 389 - log.Printf("NewPullMerged: no repo found for %s", pull.RepoAt) 390 - return 391 - } 392 - repo := repos[0] 393 358 394 359 // Don't notify yourself 395 360 if repo.Did == pull.OwnerDid { ··· 425 390 426 391 func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 427 392 // Get repo details 428 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt))) 393 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 429 394 if err != nil { 430 395 log.Printf("NewPullClosed: failed to get repos: %v", err) 431 396 return 432 397 } 433 - if len(repos) == 0 { 434 - log.Printf("NewPullClosed: no repo found for %s", pull.RepoAt) 435 - return 436 - } 437 - repo := repos[0] 438 398 439 399 // Don't notify yourself 440 400 if repo.Did == pull.OwnerDid {
+18 -13
appview/db/notifications.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 + "errors" 6 7 "fmt" 8 + "strings" 7 9 "time" 8 10 9 11 "tangled.org/core/appview/models" ··· 248 250 return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 249 251 } 250 252 251 - func (d *DB) GetUnreadNotificationCount(ctx context.Context, userDID string) (int, error) { 252 - recipientFilter := FilterEq("recipient_did", userDID) 253 - readFilter := FilterEq("read", 0) 253 + func CountNotifications(e Execer, filters ...filter) (int64, error) { 254 + var conditions []string 255 + var args []any 256 + for _, filter := range filters { 257 + conditions = append(conditions, filter.Condition()) 258 + args = append(args, filter.Arg()...) 259 + } 254 260 255 - query := fmt.Sprintf(` 256 - SELECT COUNT(*) 257 - FROM notifications 258 - WHERE %s AND %s 259 - `, recipientFilter.Condition(), readFilter.Condition()) 261 + whereClause := "" 262 + if conditions != nil { 263 + whereClause = " where " + strings.Join(conditions, " and ") 264 + } 260 265 261 - args := append(recipientFilter.Arg(), readFilter.Arg()...) 266 + query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause) 267 + var count int64 268 + err := e.QueryRow(query, args...).Scan(&count) 262 269 263 - var count int 264 - err := d.DB.QueryRowContext(ctx, query, args...).Scan(&count) 265 - if err != nil { 266 - return 0, fmt.Errorf("failed to get unread count: %w", err) 270 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 271 + return 0, err 267 272 } 268 273 269 274 return count, nil
+48 -15
appview/pages/templates/notifications/list.html
··· 11 11 </div> 12 12 </div> 13 13 14 - {{if .Notifications}} 15 - <div class="flex flex-col gap-2" id="notifications-list"> 16 - {{range .Notifications}} 17 - {{template "notifications/fragments/item" .}} 18 - {{end}} 19 - </div> 14 + {{if .Notifications}} 15 + <div class="flex flex-col gap-2" id="notifications-list"> 16 + {{range .Notifications}} 17 + {{template "notifications/fragments/item" .}} 18 + {{end}} 19 + </div> 20 20 21 - {{else}} 22 - <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 23 - <div class="text-center py-12"> 24 - <div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"> 25 - {{ i "bell-off" "w-16 h-16" }} 26 - </div> 27 - <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3> 28 - <p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p> 21 + {{else}} 22 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 23 + <div class="text-center py-12"> 24 + <div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"> 25 + {{ i "bell-off" "w-16 h-16" }} 29 26 </div> 27 + <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3> 28 + <p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p> 30 29 </div> 31 - {{end}} 30 + </div> 31 + {{end}} 32 + 33 + {{ template "pagination" . }} 34 + {{ end }} 35 + 36 + {{ define "pagination" }} 37 + <div class="flex justify-end mt-4 gap-2"> 38 + {{ if gt .Page.Offset 0 }} 39 + {{ $prev := .Page.Previous }} 40 + <a 41 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 42 + hx-boost="true" 43 + href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 44 + > 45 + {{ i "chevron-left" "w-4 h-4" }} 46 + previous 47 + </a> 48 + {{ else }} 49 + <div></div> 50 + {{ end }} 51 + 52 + {{ $next := .Page.Next }} 53 + {{ if lt $next.Offset .Total }} 54 + {{ $next := .Page.Next }} 55 + <a 56 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 57 + hx-boost="true" 58 + href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 59 + > 60 + next 61 + {{ i "chevron-right" "w-4 h-4" }} 62 + </a> 63 + {{ end }} 64 + </div> 32 65 {{ end }}
+1 -1
appview/pagination/page.go
··· 8 8 func FirstPage() Page { 9 9 return Page{ 10 10 Offset: 0, 11 - Limit: 10, 11 + Limit: 30, 12 12 } 13 13 } 14 14
+27 -208
appview/pages/templates/notifications/fragments/item.html
··· 1 1 {{define "notifications/fragments/item"}} 2 - <div 3 - class=" 4 - w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors 5 - {{if not .Read}}bg-blue-50 dark:bg-blue-900/20 border border-blue-500 dark:border-sky-800{{end}} 6 - flex gap-2 items-center 7 - " 8 - > 2 + <a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline"> 3 + <div 4 + class=" 5 + w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors 6 + {{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}} 7 + flex gap-2 items-center 8 + "> 9 + {{ template "notificationIcon" . }} 10 + <div class="flex-1 w-full flex flex-col gap-1"> 11 + <span>{{ template "notificationHeader" . }}</span> 12 + <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 13 + </div> 9 14 10 - {{ template "notificationIcon" . }} 11 - <div class="flex-1 w-full flex flex-col gap-1"> 12 - <span>{{ template "notificationHeader" . }}</span> 13 - <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 14 15 </div> 15 - 16 - </div> 16 + </a> 17 17 {{end}} 18 18 19 19 {{ define "notificationIcon" }} ··· 64 64 {{ end }} 65 65 {{ end }} 66 66 67 - {{define "issueNotification"}} 68 - {{$url := printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}} 69 - <a 70 - href="{{$url}}" 71 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 72 - > 73 - <div class="flex items-center justify-between"> 74 - <div class="min-w-0 flex-1"> 75 - <!-- First line: icon + actor action --> 76 - <div class="flex items-center gap-2 text-gray-900 dark:text-white"> 77 - {{if eq .Type "issue_created"}} 78 - <span class="text-green-600 dark:text-green-500"> 79 - {{ i "circle-dot" "w-4 h-4" }} 80 - </span> 81 - {{else if eq .Type "issue_commented"}} 82 - <span class="text-gray-500 dark:text-gray-400"> 83 - {{ i "message-circle" "w-4 h-4" }} 84 - </span> 85 - {{else if eq .Type "issue_closed"}} 86 - <span class="text-gray-500 dark:text-gray-400"> 87 - {{ i "ban" "w-4 h-4" }} 88 - </span> 89 - {{end}} 90 - {{template "user/fragments/picHandle" .ActorDid}} 91 - {{if eq .Type "issue_created"}} 92 - <span class="text-gray-500 dark:text-gray-400">opened issue</span> 93 - {{else if eq .Type "issue_commented"}} 94 - <span class="text-gray-500 dark:text-gray-400">commented on issue</span> 95 - {{else if eq .Type "issue_closed"}} 96 - <span class="text-gray-500 dark:text-gray-400">closed issue</span> 97 - {{end}} 98 - {{if not .Read}} 99 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 100 - {{end}} 101 - </div> 102 - 103 - <div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1"> 104 - <span class="text-gray-500 dark:text-gray-400">#{{.Issue.IssueId}}</span> 105 - <span class="text-gray-900 dark:text-white truncate">{{.Issue.Title}}</span> 106 - <span>on</span> 107 - <span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span> 108 - </div> 109 - </div> 110 - 111 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 112 - {{ template "repo/fragments/time" .Created }} 113 - </div> 114 - </div> 115 - </a> 116 - {{end}} 117 - 118 - {{define "pullNotification"}} 119 - {{$url := printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}} 120 - <a 121 - href="{{$url}}" 122 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 123 - > 124 - <div class="flex items-center justify-between"> 125 - <div class="min-w-0 flex-1"> 126 - <div class="flex items-center gap-2 text-gray-900 dark:text-white"> 127 - {{if eq .Type "pull_created"}} 128 - <span class="text-green-600 dark:text-green-500"> 129 - {{ i "git-pull-request-create" "w-4 h-4" }} 130 - </span> 131 - {{else if eq .Type "pull_commented"}} 132 - <span class="text-gray-500 dark:text-gray-400"> 133 - {{ i "message-circle" "w-4 h-4" }} 134 - </span> 135 - {{else if eq .Type "pull_merged"}} 136 - <span class="text-purple-600 dark:text-purple-500"> 137 - {{ i "git-merge" "w-4 h-4" }} 138 - </span> 139 - {{else if eq .Type "pull_closed"}} 140 - <span class="text-red-600 dark:text-red-500"> 141 - {{ i "git-pull-request-closed" "w-4 h-4" }} 142 - </span> 143 - {{end}} 144 - {{template "user/fragments/picHandle" (resolve .ActorDid)}} 145 - {{if eq .Type "pull_created"}} 146 - <span class="text-gray-500 dark:text-gray-400">opened pull request</span> 147 - {{else if eq .Type "pull_commented"}} 148 - <span class="text-gray-500 dark:text-gray-400">commented on pull request</span> 149 - {{else if eq .Type "pull_merged"}} 150 - <span class="text-gray-500 dark:text-gray-400">merged pull request</span> 151 - {{else if eq .Type "pull_closed"}} 152 - <span class="text-gray-500 dark:text-gray-400">closed pull request</span> 153 - {{end}} 154 - {{if not .Read}} 155 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 156 - {{end}} 157 - </div> 158 - 159 - <div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1"> 160 - <span class="text-gray-500 dark:text-gray-400">#{{.Pull.PullId}}</span> 161 - <span class="text-gray-900 dark:text-white truncate">{{.Pull.Title}}</span> 162 - <span>on</span> 163 - <span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span> 164 - </div> 165 - </div> 166 - 167 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 168 - {{ template "repo/fragments/time" .Created }} 169 - </div> 170 - </div> 171 - </a> 172 - {{end}} 173 - 174 - {{define "repoNotification"}} 175 - {{$url := printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}} 176 - <a 177 - href="{{$url}}" 178 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 179 - > 180 - <div class="flex items-center justify-between"> 181 - <div class="flex items-center gap-2 min-w-0 flex-1"> 182 - <span class="text-yellow-500 dark:text-yellow-400"> 183 - {{ i "star" "w-4 h-4" }} 184 - </span> 185 - 186 - <div class="min-w-0 flex-1"> 187 - <!-- Single line for stars: actor action subject --> 188 - <div class="flex items-center gap-1 text-gray-900 dark:text-white"> 189 - {{template "user/fragments/picHandle" (resolve .ActorDid)}} 190 - <span class="text-gray-500 dark:text-gray-400">starred</span> 191 - <span class="font-medium">{{resolve .Repo.Did}}/{{.Repo.Name}}</span> 192 - {{if not .Read}} 193 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 194 - {{end}} 195 - </div> 196 - </div> 197 - </div> 198 - 199 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 200 - {{ template "repo/fragments/time" .Created }} 201 - </div> 202 - </div> 203 - </a> 204 - {{end}} 205 - 206 - {{define "followNotification"}} 207 - {{$url := printf "/%s" (resolve .ActorDid)}} 208 - <a 209 - href="{{$url}}" 210 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 211 - > 212 - <div class="flex items-center justify-between"> 213 - <div class="flex items-center gap-2 min-w-0 flex-1"> 214 - <span class="text-blue-600 dark:text-blue-400"> 215 - {{ i "user-plus" "w-4 h-4" }} 216 - </span> 217 - 218 - <div class="min-w-0 flex-1"> 219 - <div class="flex items-center gap-1 text-gray-900 dark:text-white"> 220 - {{template "user/fragments/picHandle" (resolve .ActorDid)}} 221 - <span class="text-gray-500 dark:text-gray-400">followed you</span> 222 - {{if not .Read}} 223 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 224 - {{end}} 225 - </div> 226 - </div> 227 - </div> 228 - 229 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 230 - {{ template "repo/fragments/time" .Created }} 231 - </div> 232 - </div> 233 - </a> 234 - {{end}} 235 - 236 - {{define "genericNotification"}} 237 - <a 238 - href="#" 239 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 240 - > 241 - <div class="flex items-center justify-between"> 242 - <div class="flex items-center gap-2 min-w-0 flex-1"> 243 - <span class="{{if not .Read}}text-blue-600 dark:text-blue-400{{else}}text-gray-500 dark:text-gray-400{{end}}"> 244 - {{ i "bell" "w-4 h-4" }} 245 - </span> 246 - 247 - <div class="min-w-0 flex-1"> 248 - <div class="flex items-center gap-1 text-gray-900 dark:text-white"> 249 - <span>New notification</span> 250 - {{if not .Read}} 251 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 252 - {{end}} 253 - </div> 254 - </div> 255 - </div> 67 + {{ define "notificationUrl" }} 68 + {{ $url := "" }} 69 + {{ if eq .Type "repo_starred" }} 70 + {{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}} 71 + {{ else if .Issue }} 72 + {{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}} 73 + {{ else if .Pull }} 74 + {{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}} 75 + {{ else if eq .Type "followed" }} 76 + {{$url = printf "/%s" (resolve .ActorDid)}} 77 + {{ else }} 78 + {{ end }} 256 79 257 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 258 - {{ template "repo/fragments/time" .Created }} 259 - </div> 260 - </div> 261 - </a> 262 - {{end}} 80 + {{ $url }} 81 + {{ end }}
+1
appview/pages/templates/user/signup.html
··· 8 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 9 <meta property="og:description" content="sign up for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>sign up &middot; tangled</title> 13 14
+5
appview/models/repo.go
··· 86 86 RepoAt syntax.ATURI 87 87 LabelAt syntax.ATURI 88 88 } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+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 }}
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 1 + {{ define "repo/issues/fragments/issueListing" }} 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"> 6 + <a 7 + href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" 8 + class="no-underline hover:underline" 9 + > 10 + {{ .Title | description }} 11 + <span class="text-gray-500">#{{ .IssueId }}</span> 12 + </a> 13 + </div> 14 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 15 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 16 + {{ $icon := "ban" }} 17 + {{ $state := "closed" }} 18 + {{ if .Open }} 19 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ $state = "open" }} 22 + {{ end }} 23 + 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 + <span class="text-white dark:text-white">{{ $state }}</span> 27 + </span> 28 + 29 + <span class="ml-1"> 30 + {{ template "user/fragments/picHandleLink" .Did }} 31 + </span> 32 + 33 + <span class="before:content-['·']"> 34 + {{ template "repo/fragments/time" .Created }} 35 + </span> 36 + 37 + <span class="before:content-['·']"> 38 + {{ $s := "s" }} 39 + {{ if eq (len .Comments) 1 }} 40 + {{ $s = "" }} 41 + {{ end }} 42 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 43 + </span> 44 + 45 + {{ $state := .Labels }} 46 + {{ range $k, $d := $.LabelDefs }} 47 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 48 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 49 + {{ end }} 50 + {{ end }} 51 + </div> 52 + </div> 53 + {{ end }} 54 + </div> 55 + {{ end }}
+1
appview/pages/templates/timeline/timeline.html
··· 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ end }} 15 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 16 17 {{ template "timeline/fragments/trending" . }} 17 18 {{ template "timeline/fragments/timeline" . }} 18 19 {{ end }}
+2 -5
appview/pages/templates/timeline/fragments/goodfirstissues.html
··· 6 6 <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 7 <p> 8 8 Make your first contribution to an open-source project this October. 9 - </p> 10 - <p> 11 - <em>good-first-issue</em> is a collection of issues on open-source projects 12 - that are easy ways for new contributors to give back to the projects 13 - they love. 9 + <em>good-first-issue</em> helps new contributors find easy ways to 10 + start contributing to open-source projects. 14 11 </p> 15 12 <span class="flex items-center gap-2 text-purple-500 dark:text-purple-400"> 16 13 Browse issues {{ i "arrow-right" "size-4" }}
+3 -4
appview/pages/templates/goodfirstissues/index.html
··· 37 37 {{ else }} 38 38 {{ range .RepoGroups }} 39 39 <div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800"> 40 - <div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between"> 40 + <div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap"> 41 41 <div class="font-medium dark:text-white flex items-center justify-between"> 42 42 <div class="flex items-center min-w-0 flex-1 mr-2"> 43 43 {{ if .Repo.Source }} ··· 103 103 <div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400"> 104 104 <span> 105 105 <div class="inline-flex items-center gap-1"> 106 - {{ i "message-square" "w-3 h-3 md:hidden" }} 106 + {{ i "message-square" "w-3 h-3" }} 107 107 {{ len .Comments }} 108 - <span class="hidden md:inline">comment{{ if ne (len .Comments) 1 }}s{{ end }}</span> 109 108 </div> 110 109 </span> 111 110 <span class="before:content-['·'] before:select-none"></span> 112 111 <span class="text-sm"> 113 - {{ template "repo/fragments/time" .Created }} 112 + {{ template "repo/fragments/shortTimeAgo" .Created }} 114 113 </span> 115 114 <div class="hidden md:inline-flex md:gap-1"> 116 115 {{ $labelState := .Labels }}
+13 -1
appview/state/gfi.go
··· 47 47 repoUris = append(repoUris, rl.RepoAt.String()) 48 48 } 49 49 50 - allIssues, err := db.GetIssues( 50 + allIssues, err := db.GetIssuesPaginated( 51 51 s.db, 52 + pagination.Page{ 53 + Limit: 500, 54 + }, 52 55 db.FilterIn("repo_at", repoUris), 53 56 db.FilterEq("open", 1), 54 57 ) ··· 83 86 } 84 87 85 88 sort.Slice(sortedGroups, func(i, j int) bool { 89 + iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid 90 + jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid 91 + 92 + // If one is tangled and the other isn't, non-tangled comes first 93 + if iIsTangled != jIsTangled { 94 + return jIsTangled // true if j is tangled (i should come first) 95 + } 96 + 97 + // Both tangled or both not tangled: sort by name 86 98 return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 87 99 }) 88 100
+5 -14
appview/middleware/middleware.go
··· 43 43 44 44 type middlewareFunc func(http.Handler) http.Handler 45 45 46 - func (mw *Middleware) TryRefreshSession() middlewareFunc { 47 - return func(next http.Handler) http.Handler { 48 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 - _, _, _ = mw.oauth.GetSession(r) 50 - next.ServeHTTP(w, r) 51 - }) 52 - } 53 - } 54 - 55 - func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 56 47 return func(next http.Handler) http.Handler { 57 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 58 49 returnURL := "/" ··· 72 63 } 73 64 } 74 65 75 - _, auth, err := a.GetSession(r) 66 + sess, err := o.ResumeSession(r) 76 67 if err != nil { 77 - log.Println("not logged in, redirecting", "err", err) 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 78 69 redirectFunc(w, r) 79 70 return 80 71 } 81 72 82 - if !auth { 83 - log.Printf("not logged in, redirecting") 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 84 75 redirectFunc(w, r) 85 76 return 86 77 }
+147
appview/oauth/store.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/redis/go-redis/v9" 12 + ) 13 + 14 + // redis-backed implementation of ClientAuthStore. 15 + type RedisStore struct { 16 + client *redis.Client 17 + SessionTTL time.Duration 18 + AuthRequestTTL time.Duration 19 + } 20 + 21 + var _ oauth.ClientAuthStore = &RedisStore{} 22 + 23 + func NewRedisStore(redisURL string) (*RedisStore, error) { 24 + opts, err := redis.ParseURL(redisURL) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 + } 28 + 29 + client := redis.NewClient(opts) 30 + 31 + // test the connection 32 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 + defer cancel() 34 + 35 + if err := client.Ping(ctx).Err(); err != nil { 36 + return nil, fmt.Errorf("failed to connect to redis: %w", err) 37 + } 38 + 39 + return &RedisStore{ 40 + client: client, 41 + SessionTTL: 30 * 24 * time.Hour, // 30 days 42 + AuthRequestTTL: 10 * time.Minute, // 10 minutes 43 + }, nil 44 + } 45 + 46 + func (r *RedisStore) Close() error { 47 + return r.client.Close() 48 + } 49 + 50 + func sessionKey(did syntax.DID, sessionID string) string { 51 + return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 + } 53 + 54 + func authRequestKey(state string) string { 55 + return fmt.Sprintf("oauth:auth_request:%s", state) 56 + } 57 + 58 + func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 + key := sessionKey(did, sessionID) 60 + data, err := r.client.Get(ctx, key).Bytes() 61 + if err == redis.Nil { 62 + return nil, fmt.Errorf("session not found: %s", did) 63 + } 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get session: %w", err) 66 + } 67 + 68 + var sess oauth.ClientSessionData 69 + if err := json.Unmarshal(data, &sess); err != nil { 70 + return nil, fmt.Errorf("failed to unmarshal session: %w", err) 71 + } 72 + 73 + return &sess, nil 74 + } 75 + 76 + func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 + key := sessionKey(sess.AccountDID, sess.SessionID) 78 + 79 + data, err := json.Marshal(sess) 80 + if err != nil { 81 + return fmt.Errorf("failed to marshal session: %w", err) 82 + } 83 + 84 + if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 + return fmt.Errorf("failed to save session: %w", err) 86 + } 87 + 88 + return nil 89 + } 90 + 91 + func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 + key := sessionKey(did, sessionID) 93 + if err := r.client.Del(ctx, key).Err(); err != nil { 94 + return fmt.Errorf("failed to delete session: %w", err) 95 + } 96 + return nil 97 + } 98 + 99 + func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 100 + key := authRequestKey(state) 101 + data, err := r.client.Get(ctx, key).Bytes() 102 + if err == redis.Nil { 103 + return nil, fmt.Errorf("request info not found: %s", state) 104 + } 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to get auth request: %w", err) 107 + } 108 + 109 + var req oauth.AuthRequestData 110 + if err := json.Unmarshal(data, &req); err != nil { 111 + return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) 112 + } 113 + 114 + return &req, nil 115 + } 116 + 117 + func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 118 + key := authRequestKey(info.State) 119 + 120 + // check if already exists (to match MemStore behavior) 121 + exists, err := r.client.Exists(ctx, key).Result() 122 + if err != nil { 123 + return fmt.Errorf("failed to check auth request existence: %w", err) 124 + } 125 + if exists > 0 { 126 + return fmt.Errorf("auth request already saved for state %s", info.State) 127 + } 128 + 129 + data, err := json.Marshal(info) 130 + if err != nil { 131 + return fmt.Errorf("failed to marshal auth request: %w", err) 132 + } 133 + 134 + if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 135 + return fmt.Errorf("failed to save auth request: %w", err) 136 + } 137 + 138 + return nil 139 + } 140 + 141 + func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 142 + key := authRequestKey(state) 143 + if err := r.client.Del(ctx, key).Err(); err != nil { 144 + return fmt.Errorf("failed to delete auth request: %w", err) 145 + } 146 + return nil 147 + }
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 3 id="pull-comment-card-{{ .RoundNumber }}" 4 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 6 + {{ resolve .LoggedInUser.Did }} 7 7 </div> 8 8 <form 9 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+2 -2
appview/settings/settings.go
··· 470 470 } 471 471 472 472 // store in pds too 473 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 473 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 474 474 Collection: tangled.PublicKeyNSID, 475 475 Repo: did, 476 476 Rkey: rkey, ··· 527 527 528 528 if rkey != "" { 529 529 // remove from pds too 530 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 530 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 531 531 Collection: tangled.PublicKeyNSID, 532 532 Repo: did, 533 533 Rkey: rkey,
+5 -5
appview/spindles/spindles.go
··· 189 189 return 190 190 } 191 191 192 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 193 193 var exCid *string 194 194 if ex != nil { 195 195 exCid = ex.Cid 196 196 } 197 197 198 198 // re-announce by registering under same rkey 199 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 200 Collection: tangled.SpindleNSID, 201 201 Repo: user.Did, 202 202 Rkey: instance, ··· 332 332 return 333 333 } 334 334 335 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 336 336 Collection: tangled.SpindleNSID, 337 337 Repo: user.Did, 338 338 Rkey: instance, ··· 542 542 return 543 543 } 544 544 545 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 546 546 Collection: tangled.SpindleMemberNSID, 547 547 Repo: user.Did, 548 548 Rkey: rkey, ··· 683 683 } 684 684 685 685 // remove from pds 686 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 687 687 Collection: tangled.SpindleMemberNSID, 688 688 Repo: user.Did, 689 689 Rkey: members[0].Rkey,
+2 -2
appview/state/star.go
··· 40 40 case http.MethodPost: 41 41 createdAt := time.Now().Format(time.RFC3339) 42 42 rkey := tid.TID() 43 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 44 Collection: tangled.FeedStarNSID, 45 45 Repo: currentUser.Did, 46 46 Rkey: rkey, ··· 92 92 return 93 93 } 94 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 96 Collection: tangled.FeedStarNSID, 97 97 Repo: currentUser.Did, 98 98 Rkey: star.Rkey,
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0"> 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 3 {{ template "basicLabels" . }} 4 4 {{ template "kvLabels" . }} 5 5 </div>
+7 -2
appview/pages/templates/repo/issues/fragments/newComment.html
··· 138 138 </div> 139 139 </form> 140 140 {{ else }} 141 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 - <a href="/login" class="underline">login</a> to join the discussion 141 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 142 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 143 + sign up 144 + </a> 145 + <span class="text-gray-500 dark:text-gray-400">or</span> 146 + <a href="/login" class="underline">login</a> 147 + to add to the discussion 143 148 </div> 144 149 {{ end }} 145 150 {{ end }}
+4 -2
appview/pages/templates/repo/issues/issue.html
··· 110 110 <div class="flex items-center gap-2"> 111 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 112 {{ range $kind := .OrderedReactionKinds }} 113 + {{ $reactionData := index $.Reactions $kind }} 113 114 {{ 114 115 template "repo/fragments/reaction" 115 116 (dict 116 117 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 118 + "Count" $reactionData.Count 118 119 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 120 + "ThreadAt" $.Issue.AtUri 121 + "Users" $reactionData.Users) 120 122 }} 121 123 {{ end }} 122 124 </div>
+6 -1
appview/pages/markup/markdown.go
··· 5 5 "bytes" 6 6 "fmt" 7 7 "io" 8 + "io/fs" 8 9 "net/url" 9 10 "path" 10 11 "strings" ··· 20 21 "github.com/yuin/goldmark/renderer/html" 21 22 "github.com/yuin/goldmark/text" 22 23 "github.com/yuin/goldmark/util" 24 + callout "gitlab.com/staticnoise/goldmark-callout" 23 25 htmlparse "golang.org/x/net/html" 24 26 25 27 "tangled.org/core/api/tangled" ··· 45 47 IsDev bool 46 48 RendererType RendererType 47 49 Sanitizer Sanitizer 50 + Files fs.FS 48 51 } 49 52 50 53 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 62 65 extension.WithFootnoteIDPrefix([]byte("footnote")), 63 66 ), 64 67 treeblood.MathML(), 68 + callout.CalloutExtention, 65 69 ), 66 70 goldmark.WithParserOptions( 67 71 parser.WithAutoHeadingID(), ··· 140 144 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 141 145 switch node.Type { 142 146 case htmlparse.ElementNode: 143 - if node.Data == "img" || node.Data == "source" { 147 + switch node.Data { 148 + case "img", "source": 144 149 for i, attr := range node.Attr { 145 150 if attr.Key != "src" { 146 151 continue
+71 -12
input.css
··· 134 134 } 135 135 136 136 .prose hr { 137 - @apply my-2; 137 + @apply my-2; 138 138 } 139 139 140 140 .prose li:has(input) { 141 - @apply list-none; 141 + @apply list-none; 142 142 } 143 143 144 144 .prose ul:has(input) { 145 - @apply pl-2; 145 + @apply pl-2; 146 146 } 147 147 148 148 .prose .heading .anchor { 149 - @apply no-underline mx-2 opacity-0; 149 + @apply no-underline mx-2 opacity-0; 150 150 } 151 151 152 152 .prose .heading:hover .anchor { 153 - @apply opacity-70; 153 + @apply opacity-70; 154 154 } 155 155 156 156 .prose .heading .anchor:hover { 157 - @apply opacity-70; 157 + @apply opacity-70; 158 158 } 159 159 160 160 .prose a.footnote-backref { 161 - @apply no-underline; 161 + @apply no-underline; 162 162 } 163 163 164 164 .prose li { 165 - @apply my-0 py-0; 165 + @apply my-0 py-0; 166 166 } 167 167 168 - .prose ul, .prose ol { 169 - @apply my-1 py-0; 168 + .prose ul, 169 + .prose ol { 170 + @apply my-1 py-0; 170 171 } 171 172 172 173 .prose img { ··· 176 177 } 177 178 178 179 .prose input { 179 - @apply inline-block my-0 mb-1 mx-1; 180 + @apply inline-block my-0 mb-1 mx-1; 180 181 } 181 182 182 183 .prose input[type="checkbox"] { 183 184 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 184 185 } 186 + 187 + /* Base callout */ 188 + details[data-callout] { 189 + @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4; 190 + } 191 + 192 + details[data-callout] > summary { 193 + @apply font-bold cursor-pointer mb-1; 194 + } 195 + 196 + details[data-callout] > .callout-content { 197 + @apply text-sm leading-snug; 198 + } 199 + 200 + /* Note (blue) */ 201 + details[data-callout="note" i] { 202 + @apply border-blue-400 dark:border-blue-500; 203 + } 204 + details[data-callout="note" i] > summary { 205 + @apply text-blue-700 dark:text-blue-400; 206 + } 207 + 208 + /* Important (purple) */ 209 + details[data-callout="important" i] { 210 + @apply border-purple-400 dark:border-purple-500; 211 + } 212 + details[data-callout="important" i] > summary { 213 + @apply text-purple-700 dark:text-purple-400; 214 + } 215 + 216 + /* Warning (yellow) */ 217 + details[data-callout="warning" i] { 218 + @apply border-yellow-400 dark:border-yellow-500; 219 + } 220 + details[data-callout="warning" i] > summary { 221 + @apply text-yellow-700 dark:text-yellow-400; 222 + } 223 + 224 + /* Caution (red) */ 225 + details[data-callout="caution" i] { 226 + @apply border-red-400 dark:border-red-500; 227 + } 228 + details[data-callout="caution" i] > summary { 229 + @apply text-red-700 dark:text-red-400; 230 + } 231 + 232 + /* Tip (green) */ 233 + details[data-callout="tip" i] { 234 + @apply border-green-400 dark:border-green-500; 235 + } 236 + details[data-callout="tip" i] > summary { 237 + @apply text-green-700 dark:text-green-400; 238 + } 239 + 240 + /* Optional: hide the disclosure arrow like GitHub */ 241 + details[data-callout] > summary::-webkit-details-marker { 242 + display: none; 243 + } 185 244 } 186 245 @layer utilities { 187 246 .error { ··· 228 287 } 229 288 /* LineHighlight */ 230 289 .chroma .hl { 231 - @apply bg-amber-400/30 dark:bg-amber-500/20; 290 + @apply bg-amber-400/30 dark:bg-amber-500/20; 232 291 } 233 292 234 293 /* LineNumbersTable */
+44
appview/pages/templates/fragments/dolly/silhouette.svg
··· 1 + <svg 2 + version="1.1" 3 + id="svg1" 4 + width="32" 5 + height="32" 6 + viewBox="0 0 25 25" 7 + sodipodi:docname="tangled_dolly_silhouette.png" 8 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 9 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 10 + xmlns="http://www.w3.org/2000/svg" 11 + xmlns:svg="http://www.w3.org/2000/svg"> 12 + <title>Dolly</title> 13 + <defs 14 + id="defs1" /> 15 + <sodipodi:namedview 16 + id="namedview1" 17 + pagecolor="#ffffff" 18 + bordercolor="#000000" 19 + borderopacity="0.25" 20 + inkscape:showpageshadow="2" 21 + inkscape:pageopacity="0.0" 22 + inkscape:pagecheckerboard="true" 23 + inkscape:deskcolor="#d1d1d1"> 24 + <inkscape:page 25 + x="0" 26 + y="0" 27 + width="25" 28 + height="25" 29 + id="page2" 30 + margin="0" 31 + bleed="0" /> 32 + </sodipodi:namedview> 33 + <g 34 + inkscape:groupmode="layer" 35 + inkscape:label="Image" 36 + id="g1"> 37 + <path 38 + class="dolly" 39 + fill="currentColor" 40 + style="stroke-width:1.12248" 41 + d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 42 + id="path1" /> 43 + </g> 44 + </svg>
+11
knotserver/git/git.go
··· 71 71 return &g, nil 72 72 } 73 73 74 + // re-open a repository and update references 75 + func (g *GitRepo) Refresh() error { 76 + refreshed, err := PlainOpen(g.path) 77 + if err != nil { 78 + return err 79 + } 80 + 81 + *g = *refreshed 82 + return nil 83 + } 84 + 74 85 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 75 86 commits := []*object.Commit{} 76 87
+1 -1
knotserver/xrpc/merge_check.go
··· 51 51 return 52 52 } 53 53 54 - err = gr.MergeCheck([]byte(data.Patch), data.Branch) 54 + err = gr.MergeCheck(data.Patch, data.Branch) 55 55 56 56 response := tangled.RepoMergeCheck_Output{ 57 57 Is_conflicted: false,
+30
api/tangled/repodeleteBranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.deleteBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoDeleteBranchNSID = "sh.tangled.repo.deleteBranch" 15 + ) 16 + 17 + // RepoDeleteBranch_Input is the input argument to a sh.tangled.repo.deleteBranch call. 18 + type RepoDeleteBranch_Input struct { 19 + Branch string `json:"branch" cborgen:"branch"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoDeleteBranch calls the XRPC method "sh.tangled.repo.deleteBranch". 24 + func RepoDeleteBranch(ctx context.Context, c util.LexClient, input *RepoDeleteBranch_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.deleteBranch", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
+5
knotserver/git/branch.go
··· 110 110 slices.Reverse(branches) 111 111 return branches, nil 112 112 } 113 + 114 + func (g *GitRepo) DeleteBranch(branch string) error { 115 + ref := plumbing.NewBranchReferenceName(branch) 116 + return g.r.Storer.RemoveReference(ref) 117 + }
+30
lexicons/repo/deleteBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.deleteBranch", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a branch on this repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "branch" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "branch": { 22 + "type": "string" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + } 30 +
+11
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 33 33 <span>comment</span> 34 34 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 35 </button> 36 + {{ if .BranchDeleteStatus }} 37 + <button 38 + hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 39 + hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 40 + hx-swap="none" 41 + 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"> 42 + {{ i "git-branch" "w-4 h-4" }} 43 + <span>delete branch</span> 44 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 45 + </button> 46 + {{ end }} 36 47 {{ if and $isPushAllowed $isOpen $isLastRound }} 37 48 {{ $disabled := "" }} 38 49 {{ if $isConflicted }}
+3 -3
appview/pages/templates/repo/commit.html
··· 80 80 {{end}} 81 81 82 82 {{ define "topbarLayout" }} 83 - <header class="px-1 col-span-full" style="z-index: 20;"> 83 + <header class="col-span-full" style="z-index: 20;"> 84 84 {{ template "layouts/fragments/topbar" . }} 85 85 </header> 86 86 {{ end }} 87 87 88 88 {{ define "mainLayout" }} 89 - <div class="px-1 col-span-full flex flex-col gap-4"> 89 + <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 90 90 {{ block "contentLayout" . }} 91 91 {{ block "content" . }}{{ end }} 92 92 {{ end }} ··· 105 105 {{ end }} 106 106 107 107 {{ define "footerLayout" }} 108 - <footer class="px-1 col-span-full mt-12"> 108 + <footer class="col-span-full mt-12"> 109 109 {{ template "layouts/fragments/footer" . }} 110 110 </footer> 111 111 {{ end }}
+1 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 28 28 29 29 {{ end }} 30 30 31 - {{ define "topbarLayout" }} 32 - <header class="px-1 col-span-full" style="z-index: 20;"> 33 - {{ template "layouts/fragments/topbar" . }} 34 - </header> 35 - {{ end }} 36 - 37 31 {{ define "mainLayout" }} 38 - <div class="px-1 col-span-full flex flex-col gap-4"> 32 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 39 33 {{ block "contentLayout" . }} 40 34 {{ block "content" . }}{{ end }} 41 35 {{ end }} ··· 53 47 </div> 54 48 {{ end }} 55 49 56 - {{ define "footerLayout" }} 57 - <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/fragments/footer" . }} 59 - </footer> 60 - {{ end }} 61 - 62 - 63 50 {{ define "contentAfter" }} 64 51 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }} 65 52 {{end}}
+1 -1
appview/pages/templates/layouts/fragments/topbar.html
··· 1 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 2 + <nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
cmd/gen.go cmd/cborgen/cborgen.go
+1 -1
flake.nix
··· 262 262 lexgen --build-file lexicon-build-config.json lexicons 263 263 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 264 264 ${pkgs.gotools}/bin/goimports -w api/tangled/* 265 - go run cmd/gen.go 265 + go run ./cmd/cborgen/ 266 266 lexgen --build-file lexicon-build-config.json lexicons 267 267 rm api/tangled/*.bak 268 268 '';
+18 -18
knotserver/git.go
··· 13 13 "tangled.org/core/knotserver/git/service" 14 14 ) 15 15 16 - func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 16 + func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 17 did := chi.URLParam(r, "did") 18 18 name := chi.URLParam(r, "name") 19 19 repoName, err := securejoin.SecureJoin(did, name) 20 20 if err != nil { 21 21 gitError(w, "repository not found", http.StatusNotFound) 22 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 22 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 23 23 return 24 24 } 25 25 26 - repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, repoName) 26 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName) 27 27 if err != nil { 28 28 gitError(w, "repository not found", http.StatusNotFound) 29 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 29 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 30 30 return 31 31 } 32 32 ··· 46 46 47 47 if err := cmd.InfoRefs(); err != nil { 48 48 gitError(w, err.Error(), http.StatusInternalServerError) 49 - d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 49 + h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 50 50 return 51 51 } 52 52 case "git-receive-pack": 53 - d.RejectPush(w, r, name) 53 + h.RejectPush(w, r, name) 54 54 default: 55 55 gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden) 56 56 } 57 57 } 58 58 59 - func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 59 + func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 60 did := chi.URLParam(r, "did") 61 61 name := chi.URLParam(r, "name") 62 - repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 63 if err != nil { 64 64 gitError(w, err.Error(), http.StatusInternalServerError) 65 - d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 65 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 66 return 67 67 } 68 68 ··· 77 77 gzipReader, err := gzip.NewReader(r.Body) 78 78 if err != nil { 79 79 gitError(w, err.Error(), http.StatusInternalServerError) 80 - d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 80 + h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 81 81 return 82 82 } 83 83 defer gzipReader.Close() ··· 88 88 w.Header().Set("Connection", "Keep-Alive") 89 89 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 90 90 91 - d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 91 + h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 92 92 93 93 cmd := service.ServiceCommand{ 94 94 GitProtocol: r.Header.Get("Git-Protocol"), ··· 100 100 w.WriteHeader(http.StatusOK) 101 101 102 102 if err := cmd.UploadPack(); err != nil { 103 - d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 103 + h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 104 104 return 105 105 } 106 106 } 107 107 108 - func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 108 + func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 109 did := chi.URLParam(r, "did") 110 110 name := chi.URLParam(r, "name") 111 - _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 111 + _, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 112 112 if err != nil { 113 113 gitError(w, err.Error(), http.StatusForbidden) 114 - d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 114 + h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 115 115 return 116 116 } 117 117 118 - d.RejectPush(w, r, name) 118 + h.RejectPush(w, r, name) 119 119 } 120 120 121 - func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 121 + func (h *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 122 // A text/plain response will cause git to print each line of the body 123 123 // prefixed with "remote: ". 124 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 131 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 132 ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 - hostname := d.c.Server.Hostname 134 + hostname := h.c.Server.Hostname 135 135 if strings.Contains(hostname, ":") { 136 136 hostname = strings.Split(hostname, ":")[0] 137 137 }
+16 -9
knotserver/router.go
··· 12 12 "tangled.org/core/knotserver/config" 13 13 "tangled.org/core/knotserver/db" 14 14 "tangled.org/core/knotserver/xrpc" 15 - tlog "tangled.org/core/log" 15 + "tangled.org/core/log" 16 16 "tangled.org/core/notifier" 17 17 "tangled.org/core/rbac" 18 18 "tangled.org/core/xrpc/serviceauth" ··· 28 28 resolver *idresolver.Resolver 29 29 } 30 30 31 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 32 - r := chi.NewRouter() 33 - 31 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier) (http.Handler, error) { 34 32 h := Knot{ 35 33 c: c, 36 34 db: db, 37 35 e: e, 38 - l: l, 36 + l: log.FromContext(ctx), 39 37 jc: jc, 40 38 n: n, 41 39 resolver: idresolver.DefaultResolver(), ··· 67 65 return nil, fmt.Errorf("failed to start jetstream: %w", err) 68 66 } 69 67 68 + return h.Router(), nil 69 + } 70 + 71 + func (h *Knot) Router() http.Handler { 72 + r := chi.NewRouter() 73 + 74 + r.Use(h.RequestLogger) 75 + 70 76 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 71 77 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 72 78 }) ··· 86 92 // Socket that streams git oplogs 87 93 r.Get("/events", h.Events) 88 94 89 - return r, nil 95 + return r 90 96 } 91 97 92 98 func (h *Knot) XrpcRouter() http.Handler { 93 - logger := tlog.New("knots") 94 - 95 99 serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 96 100 101 + l := log.SubLogger(h.l, "xrpc") 102 + 97 103 xrpc := &xrpc.Xrpc{ 98 104 Config: h.c, 99 105 Db: h.db, 100 106 Ingester: h.jc, 101 107 Enforcer: h.e, 102 - Logger: logger, 108 + Logger: l, 103 109 Notifier: h.n, 104 110 Resolver: h.resolver, 105 111 ServiceAuth: serviceAuth, 106 112 } 113 + 107 114 return xrpc.Router() 108 115 } 109 116
+5 -4
knotserver/server.go
··· 43 43 44 44 func Run(ctx context.Context, cmd *cli.Command) error { 45 45 logger := log.FromContext(ctx) 46 - iLogger := log.New("knotserver/internal") 46 + logger = log.SubLogger(logger, cmd.Name) 47 + ctx = log.IntoContext(ctx, logger) 47 48 48 49 c, err := config.Load(ctx) 49 50 if err != nil { ··· 80 81 tangled.KnotMemberNSID, 81 82 tangled.RepoPullNSID, 82 83 tangled.RepoCollaboratorNSID, 83 - }, nil, logger, db, true, c.Server.LogDids) 84 + }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 84 85 if err != nil { 85 86 logger.Error("failed to setup jetstream", "error", err) 86 87 } 87 88 88 89 notifier := notifier.New() 89 90 90 - mux, err := Setup(ctx, c, db, e, jc, logger, &notifier) 91 + mux, err := Setup(ctx, c, db, e, jc, &notifier) 91 92 if err != nil { 92 93 return fmt.Errorf("failed to setup server: %w", err) 93 94 } 94 95 95 - imux := Internal(ctx, c, db, e, iLogger, &notifier) 96 + imux := Internal(ctx, c, db, e, &notifier) 96 97 97 98 logger.Info("starting internal server", "address", c.Server.InternalListenAddr) 98 99 go http.ListenAndServe(c.Server.InternalListenAddr, imux)
+36 -28
appview/db/db.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "fmt" 7 - "log" 7 + "log/slog" 8 8 "reflect" 9 9 "strings" 10 10 11 11 _ "github.com/mattn/go-sqlite3" 12 + "tangled.org/core/log" 12 13 ) 13 14 14 15 type DB struct { 15 16 *sql.DB 17 + logger *slog.Logger 16 18 } 17 19 18 20 type Execer interface { ··· 26 28 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 27 29 } 28 30 29 - func Make(dbPath string) (*DB, error) { 31 + func Make(ctx context.Context, dbPath string) (*DB, error) { 30 32 // https://github.com/mattn/go-sqlite3#connection-string 31 33 opts := []string{ 32 34 "_foreign_keys=1", ··· 35 37 "_auto_vacuum=incremental", 36 38 } 37 39 40 + logger := log.FromContext(ctx) 41 + logger = log.SubLogger(logger, "db") 42 + 38 43 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 39 44 if err != nil { 40 45 return nil, err 41 46 } 42 47 43 - ctx := context.Background() 44 - 45 48 conn, err := db.Conn(ctx) 46 49 if err != nil { 47 50 return nil, err ··· 574 577 } 575 578 576 579 // run migrations 577 - runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 580 + runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 578 581 tx.Exec(` 579 582 alter table repos add column description text check (length(description) <= 200); 580 583 `) 581 584 return nil 582 585 }) 583 586 584 - runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 587 + runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 585 588 // add unconstrained column 586 589 _, err := tx.Exec(` 587 590 alter table public_keys ··· 604 607 return nil 605 608 }) 606 609 607 - runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 610 + runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 608 611 _, err := tx.Exec(` 609 612 alter table comments drop column comment_at; 610 613 alter table comments add column rkey text; ··· 612 615 return err 613 616 }) 614 617 615 - runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 618 + runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 616 619 _, err := tx.Exec(` 617 620 alter table comments add column deleted text; -- timestamp 618 621 alter table comments add column edited text; -- timestamp ··· 620 623 return err 621 624 }) 622 625 623 - runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 626 + runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 624 627 _, err := tx.Exec(` 625 628 alter table pulls add column source_branch text; 626 629 alter table pulls add column source_repo_at text; ··· 629 632 return err 630 633 }) 631 634 632 - runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 635 + runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 633 636 _, err := tx.Exec(` 634 637 alter table repos add column source text; 635 638 `) ··· 641 644 // 642 645 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 643 646 conn.ExecContext(ctx, "pragma foreign_keys = off;") 644 - runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 647 + runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 645 648 _, err := tx.Exec(` 646 649 create table pulls_new ( 647 650 -- identifiers ··· 698 701 }) 699 702 conn.ExecContext(ctx, "pragma foreign_keys = on;") 700 703 701 - runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 704 + runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 702 705 tx.Exec(` 703 706 alter table repos add column spindle text; 704 707 `) ··· 708 711 // drop all knot secrets, add unique constraint to knots 709 712 // 710 713 // knots will henceforth use service auth for signed requests 711 - runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 714 + runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 712 715 _, err := tx.Exec(` 713 716 create table registrations_new ( 714 717 id integer primary key autoincrement, ··· 731 734 }) 732 735 733 736 // recreate and add rkey + created columns with default constraint 734 - runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 737 + runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 735 738 // create new table 736 739 // - repo_at instead of repo integer 737 740 // - rkey field ··· 785 788 return err 786 789 }) 787 790 788 - runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 791 + runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 789 792 _, err := tx.Exec(` 790 793 alter table issues add column rkey text not null default ''; 791 794 ··· 797 800 }) 798 801 799 802 // repurpose the read-only column to "needs-upgrade" 800 - runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 803 + runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 801 804 _, err := tx.Exec(` 802 805 alter table registrations rename column read_only to needs_upgrade; 803 806 `) ··· 805 808 }) 806 809 807 810 // require all knots to upgrade after the release of total xrpc 808 - runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 811 + runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 809 812 _, err := tx.Exec(` 810 813 update registrations set needs_upgrade = 1; 811 814 `) ··· 813 816 }) 814 817 815 818 // require all knots to upgrade after the release of total xrpc 816 - runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 819 + runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 817 820 _, err := tx.Exec(` 818 821 alter table spindles add column needs_upgrade integer not null default 0; 819 822 `) ··· 831 834 // 832 835 // disable foreign-keys for the next migration 833 836 conn.ExecContext(ctx, "pragma foreign_keys = off;") 834 - runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 837 + runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 835 838 _, err := tx.Exec(` 836 839 create table if not exists issues_new ( 837 840 -- identifiers ··· 901 904 // - new columns 902 905 // * column "reply_to" which can be any other comment 903 906 // * column "at-uri" which is a generated column 904 - runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 907 + runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 905 908 _, err := tx.Exec(` 906 909 create table if not exists issue_comments ( 907 910 -- identifiers ··· 961 964 // 962 965 // disable foreign-keys for the next migration 963 966 conn.ExecContext(ctx, "pragma foreign_keys = off;") 964 - runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 967 + runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 965 968 _, err := tx.Exec(` 966 969 create table if not exists pulls_new ( 967 970 -- identifiers ··· 1042 1045 // 1043 1046 // disable foreign-keys for the next migration 1044 1047 conn.ExecContext(ctx, "pragma foreign_keys = off;") 1045 - runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1048 + runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1046 1049 _, err := tx.Exec(` 1047 1050 create table if not exists pull_submissions_new ( 1048 1051 -- identifiers ··· 1094 1097 }) 1095 1098 conn.ExecContext(ctx, "pragma foreign_keys = on;") 1096 1099 1097 - return &DB{db}, nil 1100 + return &DB{ 1101 + db, 1102 + logger, 1103 + }, nil 1098 1104 } 1099 1105 1100 1106 type migrationFn = func(*sql.Tx) error 1101 1107 1102 - func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 1108 + func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error { 1109 + logger = logger.With("migration", name) 1110 + 1103 1111 tx, err := c.BeginTx(context.Background(), nil) 1104 1112 if err != nil { 1105 1113 return err ··· 1116 1124 // run migration 1117 1125 err = migrationFn(tx) 1118 1126 if err != nil { 1119 - log.Printf("Failed to run migration %s: %v", name, err) 1127 + logger.Error("failed to run migration", "err", err) 1120 1128 return err 1121 1129 } 1122 1130 1123 1131 // mark migration as complete 1124 1132 _, err = tx.Exec("insert into migrations (name) values (?)", name) 1125 1133 if err != nil { 1126 - log.Printf("Failed to mark migration %s as complete: %v", name, err) 1134 + logger.Error("failed to mark migration as complete", "err", err) 1127 1135 return err 1128 1136 } 1129 1137 ··· 1132 1140 return err 1133 1141 } 1134 1142 1135 - log.Printf("migration %s applied successfully", name) 1143 + logger.Info("migration applied successfully") 1136 1144 } else { 1137 - log.Printf("skipped migration %s, already applied", name) 1145 + logger.Warn("skipped migration, already applied") 1138 1146 } 1139 1147 1140 1148 return nil
+15 -12
appview/notifications/notifications.go
··· 1 1 package notifications 2 2 3 3 import ( 4 - "log" 4 + "log/slog" 5 5 "net/http" 6 6 "strconv" 7 7 ··· 14 14 ) 15 15 16 16 type Notifications struct { 17 - db *db.DB 18 - oauth *oauth.OAuth 19 - pages *pages.Pages 17 + db *db.DB 18 + oauth *oauth.OAuth 19 + pages *pages.Pages 20 + logger *slog.Logger 20 21 } 21 22 22 - func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications { 23 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications { 23 24 return &Notifications{ 24 - db: database, 25 - oauth: oauthHandler, 26 - pages: pagesHandler, 25 + db: database, 26 + oauth: oauthHandler, 27 + pages: pagesHandler, 28 + logger: logger, 27 29 } 28 30 } 29 31 ··· 44 46 } 45 47 46 48 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 49 + l := n.logger.With("handler", "notificationsPage") 47 50 user := n.oauth.GetUser(r) 48 51 49 52 page, ok := r.Context().Value("page").(pagination.Page) 50 53 if !ok { 51 - log.Println("failed to get page") 54 + l.Error("failed to get page") 52 55 page = pagination.FirstPage() 53 56 } 54 57 ··· 57 60 db.FilterEq("recipient_did", user.Did), 58 61 ) 59 62 if err != nil { 60 - log.Println("failed to get total notifications:", err) 63 + l.Error("failed to get total notifications", "err", err) 61 64 n.pages.Error500(w) 62 65 return 63 66 } ··· 68 71 db.FilterEq("recipient_did", user.Did), 69 72 ) 70 73 if err != nil { 71 - log.Println("failed to get notifications:", err) 74 + l.Error("failed to get notifications", "err", err) 72 75 n.pages.Error500(w) 73 76 return 74 77 } 75 78 76 79 err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) 77 80 if err != nil { 78 - log.Println("failed to mark notifications as read:", err) 81 + l.Error("failed to mark notifications as read", "err", err) 79 82 } 80 83 81 84 unreadCount := 0
+2 -2
appview/pages/pages.go
··· 54 54 logger *slog.Logger 55 55 } 56 56 57 - func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 57 + func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages { 58 58 // initialized with safe defaults, can be overriden per use 59 59 rctx := &markup.RenderContext{ 60 60 IsDev: config.Core.Dev, ··· 72 72 rctx: rctx, 73 73 resolver: res, 74 74 templateDir: "appview/pages", 75 - logger: slog.Default().With("component", "pages"), 75 + logger: logger, 76 76 } 77 77 78 78 if p.dev {
+14 -11
appview/repo/index.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 - "log" 6 + "log/slog" 7 7 "net/http" 8 8 "net/url" 9 9 "slices" ··· 31 31 ) 32 32 33 33 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 34 + l := rp.logger.With("handler", "RepoIndex") 35 + 34 36 ref := chi.URLParam(r, "ref") 35 37 ref, _ = url.PathUnescape(ref) 36 38 37 39 f, err := rp.repoResolver.Resolve(r) 38 40 if err != nil { 39 - log.Println("failed to fully resolve repo", err) 41 + l.Error("failed to fully resolve repo", "err", err) 40 42 return 41 43 } 42 44 ··· 56 58 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 57 59 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 58 60 if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 59 - log.Println("failed to call XRPC repo.index", err) 61 + l.Error("failed to call XRPC repo.index", "err", err) 60 62 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 61 63 LoggedInUser: user, 62 64 NeedsKnotUpgrade: true, ··· 66 68 } 67 69 68 70 rp.pages.Error503(w) 69 - log.Println("failed to build index response", err) 71 + l.Error("failed to build index response", "err", err) 70 72 return 71 73 } 72 74 ··· 119 121 emails := uniqueEmails(commitsTrunc) 120 122 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 121 123 if err != nil { 122 - log.Println("failed to get email to did map", err) 124 + l.Error("failed to get email to did map", "err", err) 123 125 } 124 126 125 127 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 126 128 if err != nil { 127 - log.Println(err) 129 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 128 130 } 129 131 130 132 // TODO: a bit dirty 131 - languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 133 + languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "") 132 134 if err != nil { 133 - log.Printf("failed to compute language percentages: %s", err) 135 + l.Warn("failed to compute language percentages", "err", err) 134 136 // non-fatal 135 137 } 136 138 ··· 140 142 } 141 143 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 142 144 if err != nil { 143 - log.Printf("failed to fetch pipeline statuses: %s", err) 145 + l.Error("failed to fetch pipeline statuses", "err", err) 144 146 // non-fatal 145 147 } 146 148 ··· 162 164 163 165 func (rp *Repo) getLanguageInfo( 164 166 ctx context.Context, 167 + l *slog.Logger, 165 168 f *reporesolver.ResolvedRepo, 166 169 xrpcc *indigoxrpc.Client, 167 170 currentRef string, ··· 180 183 ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 181 184 if err != nil { 182 185 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 183 - log.Println("failed to call XRPC repo.languages", xrpcerr) 186 + l.Error("failed to call XRPC repo.languages", "err", xrpcerr) 184 187 return nil, xrpcerr 185 188 } 186 189 return nil, err ··· 210 213 err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 211 214 if err != nil { 212 215 // non-fatal 213 - log.Println("failed to cache lang results", err) 216 + l.Error("failed to cache lang results", "err", err) 214 217 } 215 218 216 219 err = tx.Commit()
+3 -1
appview/state/spindlestream.go
··· 22 22 ) 23 23 24 24 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { 25 + logger := log.FromContext(ctx) 26 + logger = log.SubLogger(logger, "spindlestream") 27 + 25 28 spindles, err := db.GetSpindles( 26 29 d, 27 30 db.FilterIsNot("verified", "null"), ··· 36 39 srcs[src] = struct{}{} 37 40 } 38 41 39 - logger := log.New("spindlestream") 40 42 cache := cache.New(c.Redis.Addr) 41 43 cursorStore := cursor.NewRedisCursorStore(cache) 42 44
+14 -9
cmd/appview/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "log" 6 - "log/slog" 7 5 "net/http" 8 6 "os" 9 7 10 8 "tangled.org/core/appview/config" 11 9 "tangled.org/core/appview/state" 10 + tlog "tangled.org/core/log" 12 11 ) 13 12 14 13 func main() { 15 - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) 16 - 17 14 ctx := context.Background() 15 + logger := tlog.New("appview") 16 + ctx = tlog.IntoContext(ctx, logger) 18 17 19 18 c, err := config.LoadConfig(ctx) 20 19 if err != nil { 21 - log.Println("failed to load config", "error", err) 20 + logger.Error("failed to load config", "error", err) 22 21 return 23 22 } 24 23 25 24 state, err := state.Make(ctx, c) 26 25 defer func() { 27 - log.Println(state.Close()) 26 + if err := state.Close(); err != nil { 27 + logger.Error("failed to close state", "err", err) 28 + } 28 29 }() 29 30 30 31 if err != nil { 31 - log.Fatal(err) 32 + logger.Error("failed to start appview", "err", err) 33 + os.Exit(-1) 32 34 } 33 35 34 - log.Println("starting server on", c.Core.ListenAddr) 35 - log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router())) 36 + logger.Info("starting server", "address", c.Core.ListenAddr) 37 + 38 + if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { 39 + logger.Error("failed to start appview", "err", err) 40 + } 36 41 }
+1 -1
jetstream/jetstream.go
··· 114 114 115 115 sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) 116 116 117 - client, err := client.NewClient(j.cfg, log.New("jetstream"), sched) 117 + client, err := client.NewClient(j.cfg, logger, sched) 118 118 if err != nil { 119 119 return fmt.Errorf("failed to create jetstream client: %w", err) 120 120 }
+5 -4
xrpc/serviceauth/service_auth.go
··· 9 9 10 10 "github.com/bluesky-social/indigo/atproto/auth" 11 11 "tangled.org/core/idresolver" 12 + "tangled.org/core/log" 12 13 xrpcerr "tangled.org/core/xrpc/errors" 13 14 ) 14 15 ··· 22 23 23 24 func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 25 return &ServiceAuth{ 25 - logger: logger, 26 + logger: log.SubLogger(logger, "serviceauth"), 26 27 resolver: resolver, 27 28 audienceDid: audienceDid, 28 29 } ··· 30 31 31 32 func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 33 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 - l := sa.logger.With("url", r.URL) 34 - 35 34 token := r.Header.Get("Authorization") 36 35 token = strings.TrimPrefix(token, "Bearer ") 37 36 ··· 42 41 43 42 did, err := s.Validate(r.Context(), token, nil) 44 43 if err != nil { 45 - l.Error("signature verification failed", "err", err) 44 + sa.logger.Error("signature verification failed", "err", err) 46 45 writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 46 return 48 47 } 49 48 49 + sa.logger.Debug("valid signature", ActorDid, did) 50 + 50 51 r = r.WithContext( 51 52 context.WithValue(r.Context(), ActorDid, did), 52 53 )
+35
spindle/middleware.go
··· 1 + package spindle 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "time" 7 + ) 8 + 9 + func (s *Spindle) RequestLogger(next http.Handler) http.Handler { 10 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 + start := time.Now() 12 + 13 + next.ServeHTTP(w, r) 14 + 15 + // Build query params as slog.Attrs for the group 16 + queryParams := r.URL.Query() 17 + queryAttrs := make([]any, 0, len(queryParams)) 18 + for key, values := range queryParams { 19 + if len(values) == 1 { 20 + queryAttrs = append(queryAttrs, slog.String(key, values[0])) 21 + } else { 22 + queryAttrs = append(queryAttrs, slog.Any(key, values)) 23 + } 24 + } 25 + 26 + s.l.LogAttrs(r.Context(), slog.LevelInfo, "", 27 + slog.Group("request", 28 + slog.String("method", r.Method), 29 + slog.String("path", r.URL.Path), 30 + slog.Group("query", queryAttrs...), 31 + slog.Duration("duration", time.Since(start)), 32 + ), 33 + ) 34 + }) 35 + }
+6 -6
spindle/server.go
··· 108 108 tangled.RepoNSID, 109 109 tangled.RepoCollaboratorNSID, 110 110 } 111 - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 111 + jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 112 112 if err != nil { 113 113 return fmt.Errorf("failed to setup jetstream client: %w", err) 114 114 } ··· 171 171 // spindle.processPipeline, which in turn enqueues the pipeline 172 172 // job in the above registered queue. 173 173 ccfg := eventconsumer.NewConsumerConfig() 174 - ccfg.Logger = logger 174 + ccfg.Logger = log.SubLogger(logger, "eventconsumer") 175 175 ccfg.Dev = cfg.Server.Dev 176 176 ccfg.ProcessFunc = spindle.processPipeline 177 177 ccfg.CursorStore = cursorStore ··· 210 210 } 211 211 212 212 func (s *Spindle) XrpcRouter() http.Handler { 213 - logger := s.l.With("route", "xrpc") 214 - 215 213 serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 216 214 215 + l := log.SubLogger(s.l, "xrpc") 216 + 217 217 x := xrpc.Xrpc{ 218 - Logger: logger, 218 + Logger: l, 219 219 Db: s.db, 220 220 Enforcer: s.e, 221 221 Engines: s.engs, ··· 305 305 306 306 ok := s.jq.Enqueue(queue.Job{ 307 307 Run: func() error { 308 - engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 308 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 309 309 RepoOwner: tpl.TriggerMetadata.Repo.Did, 310 310 RepoName: tpl.TriggerMetadata.Repo.Repo, 311 311 Workflows: workflows,
+15 -12
appview/oauth/oauth.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 + "log/slog" 6 7 "net/http" 7 8 "time" 8 9 ··· 20 21 "tangled.org/core/rbac" 21 22 ) 22 23 23 - func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver) (*OAuth, error) { 24 + type OAuth struct { 25 + ClientApp *oauth.ClientApp 26 + SessStore *sessions.CookieStore 27 + Config *config.Config 28 + JwksUri string 29 + Posthog posthog.Client 30 + Db *db.DB 31 + Enforcer *rbac.Enforcer 32 + IdResolver *idresolver.Resolver 33 + Logger *slog.Logger 34 + } 35 + 36 + func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) { 24 37 25 38 var oauthConfig oauth.ClientConfig 26 39 var clientUri string ··· 54 67 Db: db, 55 68 Enforcer: enforcer, 56 69 IdResolver: res, 70 + Logger: logger, 57 71 }, nil 58 72 } 59 73 60 - type OAuth struct { 61 - ClientApp *oauth.ClientApp 62 - SessStore *sessions.CookieStore 63 - Config *config.Config 64 - JwksUri string 65 - Posthog posthog.Client 66 - Db *db.DB 67 - Enforcer *rbac.Enforcer 68 - IdResolver *idresolver.Resolver 69 - } 70 - 71 74 func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 72 75 // first we save the did in the user session 73 76 userSession, err := o.SessStore.Get(r, SessionName)
+1 -1
.tangled/workflows/test.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 3 + branch: master 4 4 5 5 engine: nixery 6 6