Monorepo for Tangled tangled.org
6

Configure Feed

Select the types of activity you want to include in your feed.

appview: use `git.getEntry` over `repo.blob`

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
committer
Tangled
date (Jun 11, 2026, 10:52 AM +0300) commit e99c34b8 parent a6167d9c change-id tmztrpsp
+161 -81
+15 -30
appview/models/repo.go
··· 210 210 BlobContentTypeSvg 211 211 BlobContentTypeVideo 212 212 BlobContentTypeSubmodule 213 + BlobContentTypeOther 213 214 ) 214 215 215 216 func (ty BlobContentType) IsCode() bool { return ty == BlobContentTypeCode } ··· 218 219 func (ty BlobContentType) IsSvg() bool { return ty == BlobContentTypeSvg } 219 220 func (ty BlobContentType) IsVideo() bool { return ty == BlobContentTypeVideo } 220 221 func (ty BlobContentType) IsSubmodule() bool { return ty == BlobContentTypeSubmodule } 222 + func (ty BlobContentType) HasTextView() bool { 223 + return ty == BlobContentTypeCode || ty == BlobContentTypeMarkup || ty == BlobContentTypeSvg 224 + } 225 + func (ty BlobContentType) HasRenderedView() bool { 226 + return ty != BlobContentTypeCode && ty != BlobContentTypeOther 227 + } 228 + func (ty BlobContentType) HasRawView() bool { 229 + return ty != BlobContentTypeSubmodule 230 + } 221 231 222 232 type BlobView struct { 223 - HasTextView bool // can show as code/text 224 - HasRenderedView bool // can show rendered (markup/image/video/submodule) 225 - HasRawView bool // can download raw (everything except submodule) 226 - FileTooLarge bool // file too large (ignored for image files) 227 - 228 - // current display mode 229 - ShowingRendered bool // currently in rendered mode 230 - 231 233 // content type flags 232 234 ContentType BlobContentType 233 235 234 236 // Content data 235 - Contents string 236 - ContentSrc string // URL for media files 237 - Lines int 238 - SizeHint uint64 239 - } 240 - 241 - // if both views are available, then show a toggle between them 242 - func (b BlobView) ShowToggle() bool { 243 - return b.HasTextView && b.HasRenderedView 244 - } 245 - 246 - func (b BlobView) IsUnsupported() bool { 247 - // no view available, only raw 248 - return !(b.HasRenderedView || b.HasTextView) 249 - } 250 - 251 - func (b BlobView) ShowingText() bool { 252 - return !b.ShowingRendered 253 - } 254 - 255 - func (b BlobView) ShowCopy() bool { 256 - return b.ContentType.IsCode() || b.ContentType.IsMarkup() || b.ContentType.IsSvg() || b.ContentType.IsImage() 237 + ContentSrc string // URL to raw content 238 + Contents string // textual content 239 + FileTooLarge bool // textual content is too large 240 + Lines int // line count of textual content 241 + SizeHint uint64 257 242 }
+3 -2
appview/pages/pages.go
··· 1092 1092 type RepoBlobParams struct { 1093 1093 LoggedInUser *oauth.MultiAccountUser 1094 1094 RepoInfo repoinfo.RepoInfo 1095 - Active string 1095 + Active string // always "overview" 1096 1096 BreadCrumbs [][]string 1097 - BlobView models.BlobView 1097 + BlobView models.BlobView // TODO: expose this struct 1098 + ShowRendered bool 1098 1099 EmailToDid map[string]string 1099 1100 LastCommitInfo *types.LastCommitInfo 1100 1101 Ref string
+14 -14
appview/pages/templates/repo/blob.html
··· 33 33 <div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0"> 34 34 <span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ pathEscape .Ref }}">{{ .Ref }}</a></span> 35 35 36 - {{ if .BlobView.ShowingText }} 36 + {{ if (and .BlobView.ContentType.HasTextView (not .ShowRendered)) }} 37 37 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 38 38 <span>{{ .BlobView.Lines }} lines</span> 39 39 {{ end }} ··· 43 43 <span>{{ byteFmt .BlobView.SizeHint }}</span> 44 44 {{ end }} 45 45 46 - {{ if .BlobView.HasRawView }} 46 + {{ if .BlobView.ContentType.HasRawView }} 47 47 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 48 48 <a href="/{{ .RepoInfo.FullName }}/raw/{{ pathEscape .Ref }}/{{ .Path }}">View raw</a> 49 49 {{ end }} 50 50 51 - {{ if .BlobView.ShowCopy }} 51 + {{ if (or .BlobView.ContentType.HasTextView .BlobView.ContentType.IsImage) }} 52 52 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 53 53 {{ template "repo/fragments/copyFileButton" (dict "Url" (printf "/%s/raw/%s/%s" .RepoInfo.FullName (pathEscape .Ref) .Path)) }} 54 54 {{ end }} 55 55 56 - {{ if .BlobView.ShowToggle }} 56 + {{ if (and .BlobView.ContentType.HasTextView .BlobView.ContentType.HasRenderedView) }} 57 57 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 58 - <a href="/{{ .RepoInfo.FullName }}/blob/{{ pathEscape .Ref }}/{{ .Path }}?code={{ .BlobView.ShowingRendered }}" hx-boost="true"> 59 - View {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }} 58 + <a href="/{{ .RepoInfo.FullName }}/blob/{{ pathEscape .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" hx-boost="true"> 59 + View {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 60 60 </a> 61 61 {{ end }} 62 62 63 - {{ if .BlobView.ShowingText }} 63 + {{ if (and .BlobView.ContentType.HasTextView (not .ShowRendered)) }} 64 64 <div id="toggle-wrap-content" class="flex items-center"> 65 65 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 66 66 <label class="flex items-center lowercase font-normal px-1 py-0 gap-1 text-xs md:text-sm"> ··· 79 79 80 80 {{ $wrapContentClasses := "peer-has-[:checked]:*:whitespace-pre-wrap peer-has-[:checked]:*:[overflow-wrap:anywhere]" }} 81 81 82 - {{ if .BlobView.IsUnsupported }} 83 - <p class="text-center text-gray-400 dark:text-gray-500"> 84 - Previews are not supported for this file type. 85 - </p> 86 - {{ else if .BlobView.ContentType.IsSubmodule }} 82 + {{ if .BlobView.ContentType.IsSubmodule }} 87 83 <p class="text-center text-gray-400 dark:text-gray-500"> 88 84 This directory is a git submodule of <a href="{{ .BlobView.ContentSrc }}">{{ .BlobView.ContentSrc }}</a>. 89 85 </p> ··· 102 98 </div> 103 99 {{ else if .BlobView.ContentType.IsSvg }} 104 100 <div class="overflow-auto relative {{ $wrapContentClasses }}"> 105 - {{ if .BlobView.ShowingRendered }} 101 + {{ if .ShowRendered }} 106 102 <div class="text-center"> 107 103 <img src="{{ .BlobView.ContentSrc }}" 108 104 alt="{{ .Path }}" ··· 118 114 </p> 119 115 {{ else if .BlobView.ContentType.IsMarkup }} 120 116 <div class="overflow-auto relative {{ $wrapContentClasses }}"> 121 - {{ if .BlobView.ShowingRendered }} 117 + {{ if .ShowRendered }} 122 118 <div id="blob-contents" class="prose dark:prose-invert">{{ .BlobView.Contents | readme }}</div> 123 119 {{ else }} 124 120 <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> ··· 128 124 <div class="overflow-auto relative {{ $wrapContentClasses }}"> 129 125 <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 130 126 </div> 127 + {{ else }} 128 + <p class="text-center text-gray-400 dark:text-gray-500"> 129 + Previews are not supported for this file type. 130 + </p> 131 131 {{ end }} 132 132 {{ template "fragments/multiline-select" }} 133 133 {{ template "repo/fragments/copyFileScript" }}
+125 -34
appview/repo/blob.go
··· 3 3 import ( 4 4 "encoding/base64" 5 5 "fmt" 6 + "io" 7 + "mime" 6 8 "net/http" 7 9 "net/url" 8 10 "path/filepath" ··· 19 21 xrpcclient "tangled.org/core/appview/xrpcclient" 20 22 "tangled.org/core/types" 21 23 24 + "github.com/bluesky-social/indigo/util" 22 25 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 26 "github.com/go-chi/chi/v5" 24 27 "github.com/go-git/go-git/v5/plumbing" 28 + "github.com/go-git/go-git/v5/plumbing/filemode" 25 29 ) 26 30 31 + // maxBlobSize bounds inline text content; larger blobs are marked too large. 32 + const maxBlobSize = 1 << 20 // 1MiB 33 + 27 34 // the content can be one of the following: 28 35 // 29 36 // - code : text | | raw 30 37 // - markup : text | rendered | raw 31 38 // - svg : text | rendered | raw 32 - // - png : | rendered | raw 39 + // - image : | rendered | raw 33 40 // - video : | rendered | raw 34 41 // - submodule : | rendered | 35 - // - rest : | | 42 + // - rest : | | raw 36 43 func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 37 44 l := rp.logger.With("handler", "RepoBlob") 38 45 ··· 48 55 filePath := chi.URLParam(r, "*") 49 56 filePath, _ = url.PathUnescape(filePath) 50 57 58 + l = l.With("ref", ref, "path", filePath) 59 + 60 + ctx := r.Context() 61 + 51 62 xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 52 - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, f.RepoDid) 63 + resp, err := tangled.GitTempGetEntry(ctx, xrpcc, filePath, ref, f.RepoDid) 53 64 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 54 - l.Error("failed to call XRPC repo.blob", "xrpcerr", xrpcerr, "err", err) 65 + l.Error("failed to call XRPC git.getEntry", "xrpcerr", xrpcerr, "err", err) 55 66 rp.pages.Error503(w) 56 67 return 57 68 } 58 69 59 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 60 - 61 - // Use XRPC response directly instead of converting to internal types 62 70 var breadcrumbs [][]string 63 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 71 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", reporesolver.GetBaseRepoPath(r, f), url.PathEscape(ref))}) 64 72 if filePath != "" { 65 73 for idx, elem := range strings.Split(filePath, "/") { 66 74 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 67 75 } 68 76 } 69 77 70 - // Create the blob view 71 - blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 78 + blobView, err := func() (models.BlobView, error) { 79 + mode, err := filemode.New(resp.Mode) 80 + if err != nil { 81 + mode = filemode.Regular 82 + } 83 + 84 + if mode == filemode.Submodule { 85 + if resp.Submodule == nil { 86 + return models.BlobView{}, fmt.Errorf("submodule info is missing") 87 + } 88 + return models.BlobView{ 89 + ContentType: models.BlobContentTypeSubmodule, 90 + ContentSrc: resp.Submodule.Url, 91 + }, nil 92 + } 93 + 94 + blobUrl := generateBlobURL(rp.config.KnotMirror.Url, f, ref, filePath) 95 + blobReq, err := http.NewRequestWithContext(ctx, http.MethodGet, blobUrl, nil) 96 + if err != nil { 97 + return models.BlobView{}, err 98 + } 99 + blobResp, err := util.RobustHTTPClient().Do(blobReq) 100 + if err != nil { 101 + return models.BlobView{}, err 102 + } 103 + defer blobResp.Body.Close() 104 + 105 + if blobResp.StatusCode != http.StatusOK { 106 + return models.BlobView{}, fmt.Errorf("blob fetch failed: status %d", blobResp.StatusCode) 107 + } 108 + 109 + // inspect content-type header 110 + // - text/plain -> Code / Markup 111 + // - image/svg -> Svg 112 + // - image/* -> Image 113 + // - video/* -> Video 114 + // - */* -> Other 115 + mediaType, _, _ := mime.ParseMediaType(blobResp.Header.Get("Content-Type")) 116 + var contentType models.BlobContentType 117 + switch { 118 + case mediaType == "image/svg+xml": 119 + contentType = models.BlobContentTypeSvg 120 + case strings.HasPrefix(mediaType, "text/"): 121 + if markup.GetFormat(filePath) == markup.FormatMarkdown { 122 + contentType = models.BlobContentTypeMarkup 123 + } else { 124 + contentType = models.BlobContentTypeCode 125 + } 126 + case strings.HasPrefix(mediaType, "image/"): 127 + contentType = models.BlobContentTypeImage 128 + case strings.HasPrefix(mediaType, "video/"): 129 + contentType = models.BlobContentTypeVideo 130 + default: 131 + contentType = models.BlobContentTypeOther 132 + } 133 + 134 + // only text-viewable content is read inline; others stream via ContentSrc 135 + if !contentType.HasTextView() { 136 + return models.BlobView{ 137 + ContentType: contentType, 138 + ContentSrc: blobUrl, 139 + FileTooLarge: false, 140 + Contents: "", 141 + Lines: 0, 142 + SizeHint: uint64(max(blobResp.ContentLength, 0)), 143 + }, nil 144 + } 72 145 73 - user := rp.oauth.GetMultiAccountUser(r) 146 + // skip large blobs 147 + if blobResp.ContentLength > maxBlobSize || blobResp.ContentLength < 0 { 148 + return models.BlobView{ 149 + ContentType: contentType, 150 + ContentSrc: blobUrl, 151 + FileTooLarge: true, 152 + SizeHint: uint64(max(blobResp.ContentLength, 0)), 153 + }, nil 154 + } 155 + 156 + // just in case, check the size again 157 + content, err := io.ReadAll(io.LimitReader(blobResp.Body, maxBlobSize)) 158 + if err != nil { 159 + return models.BlobView{}, err 160 + } 161 + 162 + contentStr := string(content) 163 + return models.BlobView{ 164 + ContentType: contentType, 165 + ContentSrc: blobUrl, 166 + Contents: contentStr, 167 + FileTooLarge: false, 168 + Lines: countLines(contentStr), 169 + SizeHint: uint64(max(blobResp.ContentLength, 0)), 170 + }, nil 171 + }() 172 + if err != nil { 173 + l.Error("failed to render blob", "err", err) 174 + rp.pages.Error503(w) 175 + return 176 + } 74 177 75 178 // Get email to DID mapping for commit author 76 179 var emails []string ··· 85 188 86 189 var lastCommitInfo *types.LastCommitInfo 87 190 if resp.LastCommit != nil { 88 - when, _ := time.Parse(time.RFC3339, resp.LastCommit.When) 191 + when, _ := time.Parse(time.RFC3339, resp.LastCommit.Committer.When) 89 192 lastCommitInfo = &types.LastCommitInfo{ 90 - Hash: plumbing.NewHash(resp.LastCommit.Hash), 193 + Hash: plumbing.NewHash(derefString(resp.LastCommit.Hash)), 91 194 Message: resp.LastCommit.Message, 92 195 When: when, 93 196 } ··· 98 201 } 99 202 } 100 203 204 + user := rp.oauth.GetMultiAccountUser(r) 101 205 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 102 206 LoggedInUser: user, 103 207 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), ··· 105 209 BlobView: blobView, 106 210 EmailToDid: emailToDidMap, 107 211 LastCommitInfo: lastCommitInfo, 108 - Ref: resp.Ref, 109 - Path: resp.Path, 212 + ShowRendered: r.URL.Query().Get("code") != "true", 213 + Ref: ref, 214 + Path: filePath, 110 215 }) 111 216 } 112 217 ··· 126 231 filePath := chi.URLParam(r, "*") 127 232 filePath, _ = url.PathUnescape(filePath) 128 233 129 - blobURL := generateBlobURL(rp.config, f, ref, filePath) 234 + blobURL := generateBlobURL(rp.config.KnotMirror.Url, f, ref, filePath) 130 235 131 236 w.Header().Set("Cache-Control", "public, no-cache") 132 237 http.Redirect(w, r, blobURL, http.StatusFound) ··· 148 253 149 254 if resp.Submodule != nil { 150 255 view.ContentType = models.BlobContentTypeSubmodule 151 - view.HasRenderedView = true 152 256 view.ContentSrc = resp.Submodule.Url 153 257 return view 154 258 } 155 259 156 260 // Determine if binary 157 261 if (resp.IsBinary != nil && *resp.IsBinary) || (resp.FileTooLarge != nil && *resp.FileTooLarge) { 158 - view.ContentSrc = generateBlobURL(config, repo, ref, filePath) 262 + view.ContentSrc = generateBlobURL(config.KnotMirror.Url, repo, ref, filePath) 159 263 ext := strings.ToLower(filepath.Ext(resp.Path)) 160 264 161 265 switch ext { 162 266 case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".jxl", ".heic", ".heif": 163 267 view.ContentType = models.BlobContentTypeImage 164 - view.HasRawView = true 165 - view.HasRenderedView = true 166 - view.ShowingRendered = true 167 268 168 269 case ".svg": 169 270 view.ContentType = models.BlobContentTypeSvg 170 - view.HasRawView = true 171 - view.HasTextView = true 172 - view.HasRenderedView = true 173 - view.ShowingRendered = queryParams.Get("code") != "true" 174 271 if resp.Content != nil { 175 272 bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) 176 273 view.Contents = string(bytes) ··· 179 276 180 277 case ".mp4", ".webm", ".ogg", ".mov", ".avi": 181 278 view.ContentType = models.BlobContentTypeVideo 182 - view.HasRawView = true 183 - view.HasRenderedView = true 184 - view.ShowingRendered = true 185 279 } 186 280 187 281 return view 188 282 } 189 283 190 284 // otherwise, we are dealing with text content 191 - view.HasRawView = true 192 - view.HasTextView = true 193 285 194 286 if resp.Content != nil { 195 287 view.Contents = *resp.Content ··· 200 292 format := markup.GetFormat(resp.Path) 201 293 if format == markup.FormatMarkdown { 202 294 view.ContentType = models.BlobContentTypeMarkup 203 - view.HasRenderedView = true 204 - view.ShowingRendered = queryParams.Get("code") != "true" 205 295 } 206 296 207 297 return view 208 298 } 209 299 210 - func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string { 300 + func generateBlobURL(knotmirror string, repo *models.Repo, ref, filePath string) string { 211 301 query := url.Values{} 212 302 query.Set("repo", repo.RepoDid) 213 303 query.Set("ref", ref) 214 304 query.Set("path", filePath) 215 305 216 - blobURL := fmt.Sprintf("%s/xrpc/%s?%s", config.KnotMirror.Url, tangled.GitTempGetBlobNSID, query.Encode()) 306 + blobURL := fmt.Sprintf("%s/xrpc/%s?%s", knotmirror, tangled.GitTempGetBlobNSID, query.Encode()) 217 307 return blobURL 308 + // return path.Join("/", repo.RepoDid, url.PathEscape(ref), filePath) 218 309 } 219 310 220 311 // TODO: dedup with strings
+4 -1
knotmirror/xrpc/git_get_blob.go
··· 7 7 "net/http" 8 8 "path/filepath" 9 9 "slices" 10 + "strconv" 10 11 "strings" 11 12 12 13 "github.com/bluesky-social/indigo/atproto/atclient" ··· 57 58 } 58 59 defer reader.Close() 59 60 61 + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) 62 + 60 63 // default to octet-stream for large blobs 61 - if size > 1000*1000 { // 1MB 64 + if size > 1024*1024 { // 1MiB 62 65 w.Header().Set("Content-Type", "application/octet-stream") 63 66 if _, err := io.Copy(w, reader); err != nil { 64 67 l.Error("failed to serve the blob", "err", err)