Monorepo for Tangled tangled.org

appview: allows a default knot to be configured #991

open opened by willdot.net targeting master from willdot.net/tangled-fork: default-knot

This follows on from the work carried out in #836

I've added a select box in the Knots settings page which pulls in the users knots and also adds in knot1.tangled.sh. When the user selects one of these options, it will save to their profile in the database. NOTE: I haven't yet implemented adding that to the AT record because I'm not sure on how the lexicon setup works yet!

Then when users go to create a new repo / fork, if there is a value in their profile for the default knot, then that will pre select the knot to use for the new repo / fork.

This is a duplicate of https://tangled.org/tangled.org/core/pulls/858/ which had to be closed because there were merge conflicts which couldn't be resolved due to the origin fork being deleted 🙈

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:dadhhalkfcq3gucaq25hjqon/sh.tangled.repo.pull/3mcrzfonnxs22
+214 -226
Diff #3
+8 -1
appview/db/db.go
··· 1078 1078 // transfer data, constructing pull_at from pulls table 1079 1079 _, err = tx.Exec(` 1080 1080 insert into pull_submissions_new (id, pull_at, round_number, patch, created) 1081 - select 1081 + select 1082 1082 ps.id, 1083 1083 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey, 1084 1084 ps.round_number, ··· 1170 1170 create index if not exists idx_stars_created on stars(created); 1171 1171 create index if not exists idx_stars_subject_at_created on stars(subject_at, created); 1172 1172 `) 1173 + return err 1174 + }) 1175 + 1176 + orm.RunMigration(conn, logger, "add-default-knot-profile", func(tx *sql.Tx) error { 1177 + _, err := tx.Exec(` 1178 + alter table profile add column default_knot text; 1179 + `) 1173 1180 return err 1174 1181 }) 1175 1182
+11 -27
appview/db/profile.go
··· 98 98 }) 99 99 } 100 100 101 - punchcard, err := MakePunchcard( 102 - e, 103 - orm.FilterEq("did", forDid), 104 - orm.FilterGte("date", time.Now().AddDate(0, -TimeframeMonths, 0)), 105 - ) 106 - if err != nil { 107 - return nil, fmt.Errorf("error getting commits by did: %w", err) 108 - } 109 - for _, punch := range punchcard.Punches { 110 - if punch.Date.After(now) { 111 - continue 112 - } 113 - 114 - monthsAgo := monthsBetween(punch.Date, now) 115 - if monthsAgo >= TimeframeMonths { 116 - // shouldn't happen; but times are weird 117 - continue 118 - } 119 - 120 - idx := monthsAgo 121 - timeline.ByMonth[idx].Commits += punch.Count 122 - } 123 - 124 101 return &timeline, nil 125 102 } 126 103 ··· 161 138 description, 162 139 include_bluesky, 163 140 location, 164 - pronouns 141 + pronouns, 142 + default_knot 165 143 ) 166 - values (?, ?, ?, ?, ?)`, 144 + values (?, ?, ?, ?, ?, ?)`, 167 145 profile.Did, 168 146 profile.Description, 169 147 includeBskyValue, 170 148 profile.Location, 171 149 profile.Pronouns, 150 + profile.DefaultKnot, 172 151 ) 173 152 174 153 if err != nil { ··· 347 326 func GetProfile(e Execer, did string) (*models.Profile, error) { 348 327 var profile models.Profile 349 328 var pronouns sql.Null[string] 329 + var defaultKnot sql.Null[string] 350 330 351 331 profile.Did = did 352 332 353 333 includeBluesky := 0 354 334 355 335 err := e.QueryRow( 356 - `select description, include_bluesky, location, pronouns from profile where did = ?`, 336 + `select description, include_bluesky, location, pronouns, default_knot from profile where did = ?`, 357 337 did, 358 - ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns) 338 + ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns, &defaultKnot) 359 339 if err == sql.ErrNoRows { 360 340 profile := models.Profile{} 361 341 profile.Did = did ··· 372 352 373 353 if pronouns.Valid { 374 354 profile.Pronouns = pronouns.V 355 + } 356 + 357 + if defaultKnot.Valid { 358 + profile.DefaultKnot = defaultKnot.V 375 359 } 376 360 377 361 rows, err := e.Query(`select link from profile_links where did = ?`, did)
+22 -4
appview/knots/knots.go
··· 81 81 return 82 82 } 83 83 84 + availableKnots, err := k.Enforcer.GetKnotsForUser(user.Did()) 85 + if err != nil { 86 + k.Logger.Error("failed to fetch available knots for user", "err", err) 87 + w.WriteHeader(http.StatusInternalServerError) 88 + return 89 + } 90 + 91 + defaultKnot := "" 92 + profile, err := db.GetProfile(k.Db, user.Did()) 93 + if err != nil { 94 + k.Logger.Warn("gettings user profile to get default knot", "error", err) 95 + } 96 + if profile != nil { 97 + defaultKnot = profile.DefaultKnot 98 + } 99 + 84 100 k.Pages.Knots(w, pages.KnotsParams{ 85 - LoggedInUser: user, 86 - Registrations: registrations, 87 - Tabs: knotsTabs, 88 - Tab: "knots", 101 + LoggedInUser: user, 102 + Registrations: registrations, 103 + Tabs: knotsTabs, 104 + Tab: "knots", 105 + AvailableKnots: availableKnots, 106 + DefaultKnot: defaultKnot, 89 107 }) 90 108 } 91 109
+1
appview/models/profile.go
··· 20 20 Stats [2]VanityStat 21 21 PinnedRepos [6]syntax.ATURI 22 22 Pronouns string 23 + DefaultKnot string 23 24 } 24 25 25 26 func (p Profile) IsLinksEmpty() bool {
+8 -4
appview/pages/pages.go
··· 417 417 } 418 418 419 419 type KnotsParams struct { 420 - LoggedInUser *oauth.MultiAccountUser 421 - Registrations []models.Registration 422 - Tabs []map[string]any 423 - Tab string 420 + LoggedInUser *oauth.MultiAccountUser 421 + Registrations []models.Registration 422 + Tabs []map[string]any 423 + Tab string 424 + AvailableKnots []string 425 + DefaultKnot string 424 426 } 425 427 426 428 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 486 488 type NewRepoParams struct { 487 489 LoggedInUser *oauth.MultiAccountUser 488 490 Knots []string 491 + DefaultKnot string 489 492 } 490 493 491 494 func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { ··· 496 499 LoggedInUser *oauth.MultiAccountUser 497 500 Knots []string 498 501 RepoInfo repoinfo.RepoInfo 502 + DefaultKnot string 499 503 } 500 504 501 505 func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
+28
appview/pages/templates/knots/index.html
··· 31 31 <div class="flex flex-col gap-6"> 32 32 {{ block "list" . }} {{ end }} 33 33 {{ block "register" . }} {{ end }} 34 + {{ block "default-knot" . }} {{ end }} 34 35 </div> 35 36 </section> 36 37 {{ end }} ··· 60 61 </div> 61 62 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 62 63 </section> 64 + {{ end }} 65 + 66 + {{ define "default-knot" }} 67 + <section class="rounded w-full flex flex-col gap-2"> 68 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">default knot</h2> 69 + <form hx-post="/profile/default-knot" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 70 + <select 71 + id="default-knot" 72 + name="default-knot" 73 + required 74 + class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 75 + {{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}} 76 + <option value="[[none]]" class="py-1" {{ if not $.DefaultKnot }}selected{{ end }}> 77 + Choose a default knot 78 + </option> 79 + {{ range $.AvailableKnots }} 80 + <option value="{{ . }}" class="py-1" {{ if eq . $.DefaultKnot }}selected{{ end }}> 81 + {{ . }} 82 + </option> 83 + {{ end }} 84 + </select> 85 + <button class="btn flex gap-2 items-center" type="submit"> 86 + {{ i "check" "size-4" }} 87 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 + </button> 89 + </form> 90 + </section> 63 91 {{ end }} 64 92 65 93 {{ define "register" }}
+28 -35
appview/pages/templates/layouts/fragments/topbar.html
··· 53 53 /> 54 54 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 55 55 </summary> 56 - <div class="absolute right-0 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg z-50 text-sm" style="width: 14rem;"> 56 + <div class="absolute right-0 mt-4 p-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg z-50" style="width: 14rem;"> 57 57 {{ $active := .Active.Did }} 58 - {{ $linkStyle := "flex items-center gap-3 px-4 py-2 hover:no-underline hover:bg-gray-50 hover:dark:bg-gray-700/50" }} 58 + 59 + <div class="pb-2 mb-2 border-b border-gray-200 dark:border-gray-700"> 60 + <div class="flex items-center gap-2"> 61 + <img src="{{ tinyAvatar $active }}" alt="" class="rounded-full h-8 w-8 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 62 + <div class="flex-1 overflow-hidden"> 63 + <p class="font-medium text-sm truncate">{{ $active | resolve }}</p> 64 + <p class="text-xs text-green-600 dark:text-green-400">active</p> 65 + </div> 66 + </div> 67 + </div> 59 68 60 69 {{ $others := .Accounts | otherAccounts $active }} 61 70 {{ if $others }} 62 - <div class="text-sm text-gray-500 dark:text-gray-400 px-3 py-1 pt-2">switch account</div> 63 - {{ range $others }} 71 + <div class="pb-2 mb-2 border-b border-gray-200 dark:border-gray-700"> 72 + <p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Switch Account</p> 73 + {{ range $others }} 64 74 <button 65 75 type="button" 66 76 hx-post="/account/switch" 67 77 hx-vals='{"did": "{{ .Did }}"}' 68 78 hx-swap="none" 69 - class="{{$linkStyle}} w-full text-left pl-3" 79 + class="flex items-center gap-2 w-full py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-left" 70 80 > 71 - <img src="{{ tinyAvatar .Did }}" alt="" class="rounded-full size-6 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 72 - <span class="truncate flex-1">{{ .Did | resolve }}</span> 81 + <img src="{{ tinyAvatar .Did }}" alt="" class="rounded-full h-6 w-6 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 82 + <span class="text-sm truncate flex-1">{{ .Did | resolve }}</span> 73 83 </button> 74 - {{ end }} 84 + {{ end }} 85 + </div> 75 86 {{ end }} 76 87 77 - <a href="/login?mode=add_account" class="{{$linkStyle}} pl-3"> 78 - <div class="size-6 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 79 - {{ i "plus" "size-3" }} 80 - </div> 81 - 82 - <div class="text-left flex-1 min-w-0 block truncate"> 83 - add account 84 - </div> 88 + <a href="/login?mode=add_account" class="flex items-center gap-2 py-1 text-sm"> 89 + {{ i "plus" "w-4 h-4 flex-shrink-0" }} 90 + <span>Add another account</span> 85 91 </a> 86 92 87 - <div class="border-t border-gray-200 dark:border-gray-700"> 88 - <a href="/{{ $active }}" class="{{$linkStyle}}"> 89 - {{ i "user" "size-4" }} 90 - profile 91 - </a> 92 - <a href="/{{ $active }}?tab=repos" class="{{$linkStyle}}"> 93 - {{ i "book-marked" "size-4" }} 94 - repositories 95 - </a> 96 - <a href="/{{ $active }}?tab=strings" class="{{$linkStyle}}"> 97 - {{ i "line-squiggle" "size-4" }} 98 - strings 99 - </a> 100 - <a href="/settings" class="{{$linkStyle}}"> 101 - {{ i "cog" "size-4" }} 102 - settings 103 - </a> 93 + <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700 space-y-1"> 94 + <a href="/{{ $active }}" class="block py-1 text-sm">profile</a> 95 + <a href="/{{ $active }}?tab=repos" class="block py-1 text-sm">repositories</a> 96 + <a href="/{{ $active }}?tab=strings" class="block py-1 text-sm">strings</a> 97 + <a href="/settings" class="block py-1 text-sm">settings</a> 104 98 <a href="#" 105 99 hx-post="/logout" 106 100 hx-swap="none" 107 - class="{{$linkStyle}} text-red-400 hover:text-red-400 hover:bg-red-100 dark:hover:bg-red-700/20 pb-2"> 108 - {{ i "log-out" "size-4" }} 101 + class="block py-1 text-sm text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 109 102 logout 110 103 </a> 111 104 </div>
+3 -1
appview/pages/templates/repo/fork.html
··· 25 25 value="{{ . }}" 26 26 class="mr-2" 27 27 id="domain-{{ . }}" 28 - {{if eq (len $.Knots) 1}}checked{{end}} 28 + {{if eq (len $.Knots) 1}}checked 29 + {{else if eq $.DefaultKnot . }}checked 30 + {{end}} 29 31 /> 30 32 <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 31 33 </div>
+5 -3
appview/pages/templates/repo/fragments/diff.html
··· 23 23 {{ block "subsCheckbox" $ }} {{ end }} 24 24 25 25 <!-- top bar --> 26 - <div class="sticky top-0 z-30 bg-slate-100 dark:bg-gray-900 flex items-center gap-2 col-span-full h-12 p-2 {{ if $root }}mt-4{{ end }}"> 26 + <div class="sticky top-0 z-30 flex items-center gap-2 col-span-full h-12 p-2 {{ if $root }}mt-4{{ end }}"> 27 27 <!-- left panel toggle --> 28 28 {{ template "filesToggle" . }} 29 29 ··· 31 31 {{ $stat := $diff.Stats }} 32 32 {{ $count := len $diff.ChangedFiles }} 33 33 {{ template "repo/fragments/diffStatPill" $stat }} 34 - <span class="text-xs text-gray-600 dark:text-gray-400 hidden md:inline-flex">{{ $count }} changed file{{ if ne $count 1 }}s{{ end }}</span> 34 + <span class="text-xs text-gray-600 dark:text-gray-400">{{ $count }} changed file{{ if ne $count 1 }}s{{ end }}</span> 35 35 36 36 {{ if $root }} 37 37 {{ if $root.IsInterdiff }} ··· 70 70 {{ template "collapseToggle" }} 71 71 72 72 <!-- diff options --> 73 - {{ template "repo/fragments/diffOpts" $opts }} 73 + <div class="hidden md:block"> 74 + {{ template "repo/fragments/diffOpts" $opts }} 75 + </div> 74 76 75 77 <!-- right panel toggle --> 76 78 {{ block "subsToggle" $ }} {{ end }}
+3 -1
appview/pages/templates/repo/new.html
··· 155 155 class="mr-2" 156 156 id="domain-{{ . }}" 157 157 required 158 - {{if eq (len $.Knots) 1}}checked{{end}} 158 + {{if eq (len $.Knots) 1}}checked 159 + {{else if eq $.DefaultKnot . }}checked 160 + {{end}} 159 161 /> 160 162 <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 161 163 </div>
+4 -12
appview/pages/templates/repo/pulls/pull.html
··· 8 8 9 9 {{ define "mainLayout" }} 10 10 <div class="px-1 flex-grow flex flex-col gap-4"> 11 - <div class="max-w-full md:max-w-screen-lg mx-auto"> 11 + <div class="max-w-screen-lg mx-auto"> 12 12 {{ block "contentLayout" . }} 13 13 {{ block "content" . }}{{ end }} 14 14 {{ end }} ··· 51 51 {{ end }} 52 52 53 53 {{ define "repoContentLayout" }} 54 - <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 54 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4"> 55 55 <section class="bg-white col-span-1 md:col-span-8 dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white h-full flex-shrink"> 56 56 {{ block "repoContent" . }}{{ end }} 57 57 </section> ··· 246 246 {{ $lastIdx := index . 2 }} 247 247 {{ $root := index . 3 }} 248 248 {{ $round := $item.RoundNumber }} 249 - <div class=" 250 - {{ if eq $round 0 }}rounded-b{{ else }}rounded{{ end }} 251 - px-6 py-4 pr-2 pt-2 252 - {{ if eq $root.ActiveRound $round }} 253 - bg-blue-100 dark:bg-blue-900 border-b border-blue-200 dark:border-blue-700 254 - {{ else }} 255 - bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 256 - {{ end }} 257 - flex gap-2 sticky top-0 z-20"> 249 + <div class="{{ if eq $round 0 }}rounded-b{{ else }}rounded{{ end }} px-6 py-4 pr-2 pt-2 {{ if eq $root.ActiveRound $round }}bg-blue-100 dark:bg-blue-900/50 border-b border-blue-200 dark:border-blue-700{{ else }}bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700{{ end }} flex gap-2 sticky top-0 z-20"> 258 250 <!-- left column: just profile picture --> 259 251 <div class="flex-shrink-0 pt-2"> 260 252 <img ··· 294 286 <div class="flex gap-2 items-center"> 295 287 {{ if ne $root.ActiveRound $round }} 296 288 <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 297 - href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}?{{ safeUrl $root.DiffOpts.Encode }}#round-#{{ $round }}"> 289 + href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}?{{ safeUrl $root.DiffOpts.Encode }}"> 298 290 {{ i "diff" "w-4 h-4" }} 299 291 diff 300 292 </a>
+1 -1
appview/pulls/opengraph.go
··· 199 199 currentX += commentTextWidth + 40 200 200 201 201 // Draw files changed 202 - err = statusStatsArea.DrawLucideIcon("file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 202 + err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 203 203 if err != nil { 204 204 log.Printf("failed to draw file diff icon: %v", err) 205 205 }
+1 -1
appview/pulls/pulls.go
··· 228 228 reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 229 229 if err != nil { 230 230 log.Println("failed to get pull reactions") 231 + s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 231 232 } 232 233 233 234 userReactions := map[models.ReactionKind]bool{} ··· 1874 1875 record := pull.AsRecord() 1875 1876 record.PatchBlob = blob.Blob 1876 1877 record.CreatedAt = time.Now().Format(time.RFC3339) 1877 - record.Source.Sha = newSourceRev 1878 1878 1879 1879 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1880 1880 Collection: tangled.RepoPullNSID,
+19 -64
appview/repo/archive.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "io" 6 5 "net/http" 7 6 "net/url" 8 7 "strings" 9 8 9 + "tangled.org/core/api/tangled" 10 + xrpcclient "tangled.org/core/appview/xrpcclient" 11 + 12 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 10 13 "github.com/go-chi/chi/v5" 14 + "github.com/go-git/go-git/v5/plumbing" 11 15 ) 12 16 13 17 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { ··· 25 29 scheme = "https" 26 30 } 27 31 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 32 + xrpcc := &indigoxrpc.Client{ 33 + Host: host, 34 + } 28 35 didSlashRepo := f.DidSlashRepo() 29 - 30 - // build the xrpc url 31 - u, err := url.Parse(host) 32 - if err != nil { 33 - l.Error("failed to parse host URL", "err", err) 36 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, didSlashRepo) 37 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 38 + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 34 39 rp.pages.Error503(w) 35 40 return 36 41 } 37 - 38 - u.Path = "/xrpc/sh.tangled.repo.archive" 39 - query := url.Values{} 40 - query.Set("format", "tar.gz") 41 - query.Set("prefix", r.URL.Query().Get("prefix")) 42 - query.Set("ref", ref) 43 - query.Set("repo", didSlashRepo) 44 - u.RawQuery = query.Encode() 45 - 46 - xrpcURL := u.String() 47 - 48 - // make the get request 49 - resp, err := http.Get(xrpcURL) 50 - if err != nil { 51 - l.Error("failed to call XRPC repo.archive", "err", err) 52 - rp.pages.Error503(w) 53 - return 54 - } 55 - 56 - // pass through headers from upstream response 57 - if contentDisposition := resp.Header.Get("Content-Disposition"); contentDisposition != "" { 58 - w.Header().Set("Content-Disposition", contentDisposition) 59 - } 60 - if contentType := resp.Header.Get("Content-Type"); contentType != "" { 61 - w.Header().Set("Content-Type", contentType) 62 - } 63 - if contentLength := resp.Header.Get("Content-Length"); contentLength != "" { 64 - w.Header().Set("Content-Length", contentLength) 65 - } 66 - if link := resp.Header.Get("Link"); link != "" { 67 - if resolvedRef, err := extractImmutableLink(link); err == nil { 68 - newLink := fmt.Sprintf("<%s/%s/archive/%s.tar.gz>; rel=\"immutable\"", 69 - rp.config.Core.AppviewHost, f.DidSlashRepo(), resolvedRef) 70 - w.Header().Set("Link", newLink) 71 - } 72 - } 73 - 74 - // stream the archive data directly 75 - if _, err := io.Copy(w, resp.Body); err != nil { 76 - l.Error("failed to write response", "err", err) 77 - } 78 - } 79 - 80 - func extractImmutableLink(linkHeader string) (string, error) { 81 - trimmed := strings.TrimPrefix(linkHeader, "<") 82 - trimmed = strings.TrimSuffix(trimmed, ">; rel=\"immutable\"") 83 - 84 - parsedLink, err := url.Parse(trimmed) 85 - if err != nil { 86 - return "", err 87 - } 88 - 89 - resolvedRef := parsedLink.Query().Get("ref") 90 - if resolvedRef == "" { 91 - return "", fmt.Errorf("no ref found in link header") 92 - } 93 - 94 - return resolvedRef, nil 42 + // Set headers for file download, just pass along whatever the knot specifies 43 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 44 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 45 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 46 + w.Header().Set("Content-Type", "application/gzip") 47 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 48 + // Write the archive data directly 49 + w.Write(archiveBytes) 95 50 }
+10
appview/repo/repo.go
··· 1004 1004 return 1005 1005 } 1006 1006 1007 + defaultKnot := "" 1008 + profile, err := db.GetProfile(rp.db, user.Did()) 1009 + if err != nil { 1010 + rp.logger.Warn("gettings user profile to get default knot", "error", err) 1011 + } 1012 + if profile != nil { 1013 + defaultKnot = profile.DefaultKnot 1014 + } 1015 + 1007 1016 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1008 1017 LoggedInUser: user, 1009 1018 Knots: knots, 1010 1019 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1020 + DefaultKnot: defaultKnot, 1011 1021 }) 1012 1022 1013 1023 case http.MethodPost:
+51
appview/state/profile.go
··· 162 162 l.Error("failed to create timeline", "err", err) 163 163 } 164 164 165 + // populate commit counts in the timeline, using the punchcard 166 + now := time.Now() 167 + for _, p := range profile.Punchcard.Punches { 168 + years := now.Year() - p.Date.Year() 169 + months := int(now.Month() - p.Date.Month()) 170 + monthsAgo := years*12 + months 171 + if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) { 172 + timeline.ByMonth[monthsAgo].Commits += p.Count 173 + } 174 + } 175 + 165 176 s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 166 177 LoggedInUser: s.oauth.GetMultiAccountUser(r), 167 178 Card: profile, ··· 603 614 profile.PinnedRepos = pinnedRepos 604 615 605 616 s.updateProfile(profile, w, r) 617 + } 618 + 619 + func (s *State) UpdateProfileDefaultKnot(w http.ResponseWriter, r *http.Request) { 620 + err := r.ParseForm() 621 + if err != nil { 622 + log.Println("invalid profile update form", err) 623 + return 624 + } 625 + user := s.oauth.GetUser(r) 626 + 627 + profile, err := db.GetProfile(s.db, user.Did) 628 + if err != nil { 629 + log.Printf("getting profile data for %s: %s", user.Did, err) 630 + } 631 + 632 + if profile == nil { 633 + return 634 + } 635 + 636 + defaultKnot := r.Form.Get("default-knot") 637 + 638 + if defaultKnot == "[[none]]" { // see pages/templates/knots/index.html for more info on why we use this value 639 + defaultKnot = "" 640 + } 641 + 642 + profile.DefaultKnot = defaultKnot 643 + 644 + tx, err := s.db.BeginTx(r.Context(), nil) 645 + if err != nil { 646 + log.Println("failed to start transaction", err) 647 + return 648 + } 649 + 650 + err = db.UpsertProfile(tx, profile) 651 + if err != nil { 652 + log.Println("failed to update profile", err) 653 + return 654 + } 655 + 656 + s.pages.HxRefresh(w) 606 657 } 607 658 608 659 func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) {
+1
appview/state/router.go
··· 165 165 r.Get("/edit-pins", s.EditPinsFragment) 166 166 r.Post("/bio", s.UpdateProfileBio) 167 167 r.Post("/pins", s.UpdateProfilePins) 168 + r.Post("/default-knot", s.UpdateProfileDefaultKnot) 168 169 }) 169 170 170 171 r.Mount("/settings", s.SettingsRouter())
+10
appview/state/state.go
··· 418 418 return 419 419 } 420 420 421 + defaultKnot := "" 422 + profile, err := db.GetProfile(s.db, user.Did()) 423 + if err != nil { 424 + s.logger.Warn("gettings user profile to get default knot", "error", err) 425 + } 426 + if profile != nil { 427 + defaultKnot = profile.DefaultKnot 428 + } 429 + 421 430 s.pages.NewRepo(w, pages.NewRepoParams{ 422 431 LoggedInUser: user, 423 432 Knots: knots, 433 + DefaultKnot: defaultKnot, 424 434 }) 425 435 426 436 case http.MethodPost:
-33
docs/DOCS.md
··· 693 693 MY_ENV_VAR: "MY_ENV_VALUE" 694 694 ``` 695 695 696 - By default, the following environment variables set: 697 - 698 - - `CI` - Always set to `true` to indicate a CI environment 699 - - `TANGLED_PIPELINE_ID` - The AT URI of the current pipeline 700 - - `TANGLED_REPO_KNOT` - The repository's knot hostname 701 - - `TANGLED_REPO_DID` - The DID of the repository owner 702 - - `TANGLED_REPO_NAME` - The name of the repository 703 - - `TANGLED_REPO_DEFAULT_BRANCH` - The default branch of the 704 - repository 705 - - `TANGLED_REPO_URL` - The full URL to the repository 706 - 707 - These variables are only available when the pipeline is 708 - triggered by a push: 709 - 710 - - `TANGLED_REF` - The full git reference (e.g., 711 - `refs/heads/main` or `refs/tags/v1.0.0`) 712 - - `TANGLED_REF_NAME` - The short name of the reference 713 - (e.g., `main` or `v1.0.0`) 714 - - `TANGLED_REF_TYPE` - The type of reference, either 715 - `branch` or `tag` 716 - - `TANGLED_SHA` - The commit SHA that triggered the pipeline 717 - - `TANGLED_COMMIT_SHA` - Alias for `TANGLED_SHA` 718 - 719 - These variables are only available when the pipeline is 720 - triggered by a pull request: 721 - 722 - - `TANGLED_PR_SOURCE_BRANCH` - The source branch of the pull 723 - request 724 - - `TANGLED_PR_TARGET_BRANCH` - The target branch of the pull 725 - request 726 - - `TANGLED_PR_SOURCE_SHA` - The commit SHA of the source 727 - branch 728 - 729 696 ### Steps 730 697 731 698 The `steps` field allows you to define what steps should run
-4
knotserver/git/git.go
··· 76 76 return &g, nil 77 77 } 78 78 79 - func (g *GitRepo) Hash() plumbing.Hash { 80 - return g.h 81 - } 82 - 83 79 // re-open a repository and update references 84 80 func (g *GitRepo) Refresh() error { 85 81 refreshed, err := PlainOpen(g.path)
-35
knotserver/xrpc/repo_archive.go
··· 4 4 "compress/gzip" 5 5 "fmt" 6 6 "net/http" 7 - "net/url" 8 7 "strings" 9 8 10 9 "github.com/go-git/go-git/v5/plumbing" 11 10 12 - "tangled.org/core/api/tangled" 13 11 "tangled.org/core/knotserver/git" 14 12 xrpcerr "tangled.org/core/xrpc/errors" 15 13 ) ··· 49 47 repoParts := strings.Split(repo, "/") 50 48 repoName := repoParts[len(repoParts)-1] 51 49 52 - immutableLink, err := x.buildImmutableLink(repo, format, gr.Hash().String(), prefix) 53 - if err != nil { 54 - x.Logger.Error( 55 - "failed to build immutable link", 56 - "err", err.Error(), 57 - "repo", repo, 58 - "format", format, 59 - "ref", gr.Hash().String(), 60 - "prefix", prefix, 61 - ) 62 - } 63 - 64 50 safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 65 51 66 52 var archivePrefix string ··· 73 59 filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 74 60 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 75 61 w.Header().Set("Content-Type", "application/gzip") 76 - w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"immutable\"", immutableLink)) 77 62 78 63 gw := gzip.NewWriter(w) 79 64 defer gw.Close() ··· 94 79 return 95 80 } 96 81 } 97 - 98 - func (x *Xrpc) buildImmutableLink(repo string, format string, ref string, prefix string) (string, error) { 99 - scheme := "https" 100 - if x.Config.Server.Dev { 101 - scheme = "http" 102 - } 103 - 104 - u, err := url.Parse(scheme + "://" + x.Config.Server.Hostname + "/xrpc/" + tangled.RepoArchiveNSID) 105 - if err != nil { 106 - return "", err 107 - } 108 - 109 - params := url.Values{} 110 - params.Set("repo", repo) 111 - params.Set("format", format) 112 - params.Set("ref", ref) 113 - params.Set("prefix", prefix) 114 - 115 - return fmt.Sprintf("%s?%s", u.String(), params.Encode()), nil 116 - }

History

8 rounds 9 comments
sign up or login to add to the discussion
1 commit
expand
appview: allow default knot to be saved as a preference and then used when creating a new repo
no conflicts, ready to merge
expand 0 comments
1 commit
expand
appview: allow default knot to be saved as a preference and then used when creating a new repo
expand 0 comments
1 commit
expand
appview: allow default knot to be saved as a preference and then used when creating a new repo
expand 0 comments
1 commit
expand
appview: allows a default knot to be selected from knots available to the user
expand 5 comments

@oppi.li While attempting to implement something else related to profiles, I've noticed that UpsertProfile(...) herehttps://tangled.org/tangled.org/core/blob/master/appview/db/profile.go#L133 also gets called as part of a JS ingester here https://tangled.org/tangled.org/core/blob/master/appview/ingester.go#L352 which means the DefaultKnot field will be overwritten with an empty string when a profile event is ingested. Am I understanding that correctly?

So instead I should probably not use the UpsertProfile(...) but use a smaller scoped function that just sets the Default Knot field?

ah that is correct, the default knot will be overwritten by that call. i suppose that is one of the drawbacks of overloading models.Profile! instead of cloning UpsertProfile to add this as an exception, perhaps this is an indication that we need to avoid using the profile table/model for this. what do you think about creating a preferences table for stuff like this? we already have notification_preferences, we could do something similar for knots.

i see you have a PR open for punchcard work as well, which would fit quite well in a punchcard_preferences table.

Yh I'm 100% down for using that approach. While I was implementing the punchcard PR I had this nagging feeling in me that this and other things belonged in some sort of separate "preferences" table so splitting them out into things like knot_preferences or punchcard_preferences is the right answer for me.

I'll redo that for this PR and then do the same of the other PR and hopefully it will prevent the merge conflicts I was dreading 🙈

yeah! knot_preferences and punchcard_preferences seems okay for now. thanks for keeping at this by the way, and sorry for the slow review cycle!

No worries! I don't expect immediate reviews 😁

I've refactored to use a new knot_preferences table for this PR. I had some merge conflicts with master though, so rebased that although took me a couple attempts to get everything nice and neat, hence so many rounds 🙈

2 commits
expand
appview: allows a default knot to be configured
appview: allows a default knot to be selected from knots available to the user
expand 0 comments
1 commit
expand
appview: allows a default knot to be configured
expand 2 comments

the code itself works pretty nicely now. so i've noticed a couple issues (apologies for not looking more closely earlier!):

  • the default-knot selector only iterates over knots that the user is an admin of, whereas they should be able to choose any knot that they are a member of as the default. you can use s.enforcer.GetKnotsForUser(...) to get a list of knots that a user has access to
  • the HTML change adds knot1.tangled.sh as an explicit case, this is not necessary if the above is implemented
  • the HTML template currently triggers a request when the select changes, could we make this more like how the spindle selector works? a form with a button to submit.

I was looking for ages trying to find somewhere that did something similar. I don't use spindles so never saw that 🙈 A much better approach.

Also good shout on the knots for a user. Reading the code now, I understand what the registrations are now.

Anyhow, I've updated the PR and squashed everything so it should be good to test now.

Please note, that I have yet to figure out how to get multiple Knots running locally, and since I don't get knot1.tangled.sh locally I had to hard code some things in to test it worked, which is less than ideal.

1 commit
expand
appview: allows a default knot to be configured
expand 0 comments
1 commit
expand
appview: allows a default knot to be configured
expand 2 comments

i get this error at runtime:

2026/01/20 04:58:12 profile err 5 values for 6 columns
2026/01/20 04:58:12 failed to update profile 5 values for 6 columns

this sql query is incorrect:

		`insert or replace into profile (
			did,
			description,
			include_bluesky,
			location,
			pronouns,
			default_knot
		)
		values (?, ?, ?, ?, ?)`,

there should be 6 ? placeholders!

Ah crap, someone needs to create a lint tool that detects that 🤦‍♂️

Fixed.