diff --git a/lexicons/io/atcr/repo/page.json b/lexicons/io/atcr/repo/page.json index 5e4a02a..b3ee717 100644 --- a/lexicons/io/atcr/repo/page.json +++ b/lexicons/io/atcr/repo/page.json @@ -26,6 +26,10 @@ "accept": ["image/png", "image/jpeg", "image/webp"], "maxSize": 3000000 }, + "userEdited": { + "type": "boolean", + "description": "Whether the description was manually edited by the user. When true, auto-population from manifest annotations is skipped on push." + }, "createdAt": { "type": "string", "format": "datetime", diff --git a/pkg/appview/db/migrations/0017_add_repo_page_user_edited.yaml b/pkg/appview/db/migrations/0017_add_repo_page_user_edited.yaml new file mode 100644 index 0000000..a05271a --- /dev/null +++ b/pkg/appview/db/migrations/0017_add_repo_page_user_edited.yaml @@ -0,0 +1,3 @@ +description: Add user_edited flag to repo_pages to prevent auto-overwrite of manually edited descriptions +query: | + ALTER TABLE repo_pages ADD COLUMN user_edited BOOLEAN NOT NULL DEFAULT 0; diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 01c9b8e..4a372b5 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -1356,28 +1356,53 @@ func GetManifestTags(db DBTX, did, repository, digest string) ([]string, error) return tags, nil } -// GetUntaggedTopLevelManifestDigests returns digests of top-level manifests that have no tags. +// GetAllUntaggedManifestDigests returns digests of all untagged manifests eligible for deletion. +// Returns children of untagged manifest lists first (bottom-up) so the handler can delete +// children before parents, avoiding orphaned manifests from cascade-deleted references. // Uses the same filtering logic as GetTopLevelManifests (manifest lists + orphaned single-arch). -func GetUntaggedTopLevelManifestDigests(db DBTX, did, repository string) ([]string, error) { +func GetAllUntaggedManifestDigests(db DBTX, did, repository string) ([]string, error) { rows, err := db.Query(` WITH manifest_list_children AS ( SELECT DISTINCT mr.digest FROM manifest_references mr JOIN manifests m ON mr.manifest_id = m.id WHERE m.did = ? AND m.repository = ? + ), + untagged_top_level AS ( + SELECT m.id, m.digest, + CASE WHEN m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%' + THEN 1 ELSE 0 END as is_list + FROM manifests m + LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository + WHERE m.did = ? AND m.repository = ? + AND ( + m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%' + OR + m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL) + ) + GROUP BY m.id + HAVING COUNT(t.tag) = 0 + ), + untagged_children AS ( + SELECT DISTINCT mr.digest + FROM untagged_top_level ul + JOIN manifest_references mr ON ul.id = mr.manifest_id + JOIN manifests child_m ON mr.digest = child_m.digest + AND child_m.did = ? AND child_m.repository = ? + LEFT JOIN tags ct ON child_m.digest = ct.digest + AND child_m.did = ct.did AND child_m.repository = ct.repository + WHERE ul.is_list = 1 AND ct.tag IS NULL + AND mr.digest NOT IN ( + SELECT mr2.digest FROM manifest_references mr2 + JOIN manifests m2 ON mr2.manifest_id = m2.id + JOIN tags t2 ON m2.digest = t2.digest AND m2.did = t2.did AND m2.repository = t2.repository + WHERE m2.did = ? AND m2.repository = ? + ) ) - SELECT m.digest - FROM manifests m - LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository - WHERE m.did = ? AND m.repository = ? - AND ( - m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%' - OR - m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL) - ) - GROUP BY m.id - HAVING COUNT(t.tag) = 0 - `, did, repository, did, repository) + SELECT digest FROM untagged_children + UNION ALL + SELECT digest FROM untagged_top_level + `, did, repository, did, repository, did, repository, did, repository) if err != nil { return nil, err } @@ -2074,22 +2099,25 @@ type RepoPage struct { Repository string Description string AvatarCID string + UserEdited bool CreatedAt time.Time UpdatedAt time.Time } // UpsertRepoPage inserts or updates a repo page record -func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, createdAt, updatedAt time.Time) error { +func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, userEdited bool, createdAt, updatedAt time.Time) error { _, err := db.Exec(` - INSERT INTO repo_pages (did, repository, description, avatar_cid, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO repo_pages (did, repository, description, avatar_cid, user_edited, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(did, repository) DO UPDATE SET description = excluded.description, avatar_cid = excluded.avatar_cid, + user_edited = excluded.user_edited, updated_at = excluded.updated_at WHERE excluded.description IS NOT repo_pages.description OR excluded.avatar_cid IS NOT repo_pages.avatar_cid - `, did, repository, description, avatarCID, createdAt, updatedAt) + OR excluded.user_edited IS NOT repo_pages.user_edited + `, did, repository, description, avatarCID, userEdited, createdAt, updatedAt) return err } @@ -2097,10 +2125,10 @@ func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, cre func GetRepoPage(db DBTX, did, repository string) (*RepoPage, error) { var rp RepoPage err := db.QueryRow(` - SELECT did, repository, description, avatar_cid, created_at, updated_at + SELECT did, repository, description, avatar_cid, user_edited, created_at, updated_at FROM repo_pages WHERE did = ? AND repository = ? - `, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt) + `, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.UserEdited, &rp.CreatedAt, &rp.UpdatedAt) if err != nil { return nil, err } @@ -2118,7 +2146,7 @@ func DeleteRepoPage(db DBTX, did, repository string) error { // GetRepoPagesByDID returns all repo pages for a DID func GetRepoPagesByDID(db DBTX, did string) ([]RepoPage, error) { rows, err := db.Query(` - SELECT did, repository, description, avatar_cid, created_at, updated_at + SELECT did, repository, description, avatar_cid, user_edited, created_at, updated_at FROM repo_pages WHERE did = ? `, did) @@ -2130,7 +2158,7 @@ func GetRepoPagesByDID(db DBTX, did string) ([]RepoPage, error) { var pages []RepoPage for rows.Next() { var rp RepoPage - if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt); err != nil { + if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.UserEdited, &rp.CreatedAt, &rp.UpdatedAt); err != nil { return nil, err } pages = append(pages, rp) diff --git a/pkg/appview/db/queries_test.go b/pkg/appview/db/queries_test.go index 8577b3f..223c68e 100644 --- a/pkg/appview/db/queries_test.go +++ b/pkg/appview/db/queries_test.go @@ -1376,3 +1376,158 @@ func TestIsManifestReferenced(t *testing.T) { t.Error("Expected sha256:childdef to NOT be referenced for different user") } } + +func TestGetAllUntaggedManifestDigests(t *testing.T) { + db, err := InitDB(":memory:", LibsqlConfig{}) + if err != nil { + t.Fatalf("Failed to init database: %v", err) + } + defer db.Close() + + did := "did:plc:test123" + repo := "myapp" + now := time.Now() + + if err := UpsertUser(db, &User{ + DID: did, + Handle: "test.bsky.social", + PDSEndpoint: "https://test.pds.example.com", + LastSeen: now, + }); err != nil { + t.Fatalf("Failed to insert user: %v", err) + } + + indexType := "application/vnd.oci.image.index.v1+json" + manifestType := "application/vnd.oci.image.manifest.v1+json" + hold := "did:web:hold.example.com" + + insertManifest := func(t *testing.T, digest, mediaType string) int64 { + t.Helper() + id, err := InsertManifest(db, &Manifest{ + DID: did, Repository: repo, Digest: digest, + HoldEndpoint: hold, SchemaVersion: 2, MediaType: mediaType, + CreatedAt: now, + }) + if err != nil { + t.Fatalf("Failed to insert manifest %s: %v", digest, err) + } + return id + } + + insertRef := func(t *testing.T, parentID int64, childDigest string, idx int) { + t.Helper() + err := InsertManifestReference(db, &ManifestReference{ + ManifestID: parentID, + Digest: childDigest, + Size: 1000, + MediaType: manifestType, + PlatformArchitecture: "amd64", + PlatformOS: "linux", + ReferenceIndex: idx, + }) + if err != nil { + t.Fatalf("Failed to insert reference: %v", err) + } + } + + insertTag := func(t *testing.T, digest, tag string) { + t.Helper() + if err := UpsertTag(db, &Tag{ + DID: did, Repository: repo, Tag: tag, + Digest: digest, CreatedAt: now, + }); err != nil { + t.Fatalf("Failed to insert tag: %v", err) + } + } + + // Setup scenario: + // + // TAGGED index "sha256:tagged-index" -> tag "v1" + // children: sha256:tagged-child-amd64, sha256:shared-child-arm64 + // + // UNTAGGED index "sha256:untagged-index" (no tag) + // children: sha256:untagged-child-amd64, sha256:shared-child-arm64 + // + // UNTAGGED orphan single-arch "sha256:orphan-single" (no tag, no parent) + // + // TAGGED single-arch "sha256:tagged-single" -> tag "latest" + + // Tagged index + its children + taggedIndexID := insertManifest(t, "sha256:tagged-index", indexType) + insertManifest(t, "sha256:tagged-child-amd64", manifestType) + insertManifest(t, "sha256:shared-child-arm64", manifestType) + insertRef(t, taggedIndexID, "sha256:tagged-child-amd64", 0) + insertRef(t, taggedIndexID, "sha256:shared-child-arm64", 1) + insertTag(t, "sha256:tagged-index", "v1") + + // Untagged index + its children + untaggedIndexID := insertManifest(t, "sha256:untagged-index", indexType) + insertManifest(t, "sha256:untagged-child-amd64", manifestType) + // sha256:shared-child-arm64 already inserted, just add the reference + insertRef(t, untaggedIndexID, "sha256:untagged-child-amd64", 0) + insertRef(t, untaggedIndexID, "sha256:shared-child-arm64", 1) + + // Orphan single-arch (no parent, no tag) + insertManifest(t, "sha256:orphan-single", manifestType) + + // Tagged single-arch + insertManifest(t, "sha256:tagged-single", manifestType) + insertTag(t, "sha256:tagged-single", "latest") + + // Run the query + digests, err := GetAllUntaggedManifestDigests(db, did, repo) + if err != nil { + t.Fatalf("GetAllUntaggedManifestDigests error: %v", err) + } + + // Build sets for easy checking + digestSet := map[string]bool{} + for _, d := range digests { + digestSet[d] = true + } + + // Should include: untagged index, its exclusive child, and the orphan single + if !digestSet["sha256:untagged-index"] { + t.Error("Expected untagged-index to be included") + } + if !digestSet["sha256:untagged-child-amd64"] { + t.Error("Expected untagged-child-amd64 to be included") + } + if !digestSet["sha256:orphan-single"] { + t.Error("Expected orphan-single to be included") + } + + // Should NOT include: tagged index, tagged children, shared child (still referenced by tagged index), tagged single + if digestSet["sha256:tagged-index"] { + t.Error("Expected tagged-index to NOT be included") + } + if digestSet["sha256:tagged-child-amd64"] { + t.Error("Expected tagged-child-amd64 to NOT be included") + } + if digestSet["sha256:shared-child-arm64"] { + t.Error("Expected shared-child-arm64 to NOT be included (still referenced by tagged index)") + } + if digestSet["sha256:tagged-single"] { + t.Error("Expected tagged-single to NOT be included") + } + + // Verify ordering: children should come before their parent index + childIdx := -1 + parentIdx := -1 + for i, d := range digests { + if d == "sha256:untagged-child-amd64" { + childIdx = i + } + if d == "sha256:untagged-index" { + parentIdx = i + } + } + if childIdx >= 0 && parentIdx >= 0 && childIdx > parentIdx { + t.Errorf("Expected children before parents: child at index %d, parent at index %d", childIdx, parentIdx) + } + + // Verify total count: untagged-child-amd64, orphan-single, untagged-index = 3 + if len(digests) != 3 { + t.Errorf("Expected 3 digests, got %d: %v", len(digests), digests) + } +} diff --git a/pkg/appview/db/schema.sql b/pkg/appview/db/schema.sql index e5e3427..a600f7d 100644 --- a/pkg/appview/db/schema.sql +++ b/pkg/appview/db/schema.sql @@ -232,6 +232,7 @@ CREATE TABLE IF NOT EXISTS repo_pages ( repository TEXT NOT NULL, description TEXT, avatar_cid TEXT, + user_edited BOOLEAN NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, PRIMARY KEY(did, repository), diff --git a/pkg/appview/handlers/images.go b/pkg/appview/handlers/images.go index 49b639b..7d5aaa7 100644 --- a/pkg/appview/handlers/images.go +++ b/pkg/appview/handlers/images.go @@ -202,7 +202,7 @@ func (h *DeleteUntaggedManifestsHandler) ServeHTTP(w http.ResponseWriter, r *htt return } - digests, err := db.GetUntaggedTopLevelManifestDigests(h.DB, user.DID, req.Repo) + digests, err := db.GetAllUntaggedManifestDigests(h.DB, user.DID, req.Repo) if err != nil { http.Error(w, fmt.Sprintf("Failed to query untagged manifests: %v", err), http.StatusInternalServerError) return diff --git a/pkg/appview/handlers/repo_editor.go b/pkg/appview/handlers/repo_editor.go new file mode 100644 index 0000000..91d355c --- /dev/null +++ b/pkg/appview/handlers/repo_editor.go @@ -0,0 +1,141 @@ +package handlers + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "time" + + "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/middleware" + "atcr.io/pkg/atproto" +) + +// SaveRepoPageHandler saves user-edited description to the PDS repo page record +type SaveRepoPageHandler struct { + BaseUIHandler +} + +func (h *SaveRepoPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + did := r.FormValue("did") + repository := r.FormValue("repository") + description := r.FormValue("description") + + if did == "" || repository == "" { + http.Error(w, "Missing required fields", http.StatusBadRequest) + return + } + + // Verify ownership + if user.DID != did { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + // Size limit + if len(description) > 100*1024 { + http.Error(w, "Description too large (max 100KB)", http.StatusBadRequest) + return + } + + pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) + + // Fetch existing record to preserve avatar and createdAt + var existingAvatar *atproto.ATProtoBlobRef + var existingCreatedAt time.Time + record, err := pdsClient.GetRecord(r.Context(), atproto.RepoPageCollection, repository) + if err == nil { + var existingRecord atproto.RepoPageRecord + if jsonErr := json.Unmarshal(record.Value, &existingRecord); jsonErr == nil { + existingAvatar = existingRecord.Avatar + existingCreatedAt = existingRecord.CreatedAt + } + } else if !errors.Is(err, atproto.ErrRecordNotFound) { + if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { + http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) + return + } + } + + // Create updated record + repoPage := atproto.NewRepoPageRecord(repository, description, existingAvatar) + if !existingCreatedAt.IsZero() { + repoPage.CreatedAt = existingCreatedAt + } + + // If description is empty, clear userEdited so auto-populate resumes + repoPage.UserEdited = description != "" + + // Save to PDS + _, err = pdsClient.PutRecord(r.Context(), atproto.RepoPageCollection, repository, repoPage) + if err != nil { + if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { + http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) + return + } + http.Error(w, "Failed to save description", http.StatusInternalServerError) + return + } + + // Update DB cache + avatarCID := "" + if existingAvatar != nil && existingAvatar.Ref.Link != "" { + avatarCID = existingAvatar.Ref.Link + } + if err := db.UpsertRepoPage(h.DB, user.DID, repository, description, avatarCID, repoPage.UserEdited, repoPage.CreatedAt, repoPage.UpdatedAt); err != nil { + slog.Warn("Failed to update repo page cache", "error", err) + } + + // Return rendered HTML for HTMX swap + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if description == "" { + _, _ = w.Write([]byte(`
No description available
`)) + return + } + html, err := h.ReadmeFetcher.RenderMarkdown([]byte(description)) + if err != nil { + http.Error(w, "Failed to render markdown", http.StatusInternalServerError) + return + } + _, _ = w.Write([]byte(html)) + return + } + + w.WriteHeader(http.StatusOK) +} + +// PreviewMarkdownHandler renders markdown to HTML for the editor preview tab +type PreviewMarkdownHandler struct { + BaseUIHandler +} + +func (h *PreviewMarkdownHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + markdown := r.FormValue("markdown") + if markdown == "" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`Nothing to preview
`)) + return + } + + if len(markdown) > 100*1024 { + http.Error(w, "Content too large", http.StatusBadRequest) + return + } + + html, err := h.ReadmeFetcher.RenderMarkdown([]byte(markdown)) + if err != nil { + http.Error(w, "Failed to render markdown", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(html)) +} diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index afaab1e..f10b655 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -122,6 +122,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request // Fetch README content from repo page record or annotations var readmeHTML template.HTML + var rawDescription string repoPage, err := db.GetRepoPage(h.ReadOnlyDB, owner.DID, repository) if err == nil && repoPage != nil { @@ -129,6 +130,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID) } if repoPage.Description != "" && h.ReadmeFetcher != nil { + rawDescription = repoPage.Description html, err := h.ReadmeFetcher.RenderMarkdown([]byte(repoPage.Description)) if err != nil { slog.Warn("Failed to render repo page description", "error", err) @@ -146,11 +148,18 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } } if readmeURL != "" { - html, err := h.ReadmeFetcher.FetchAndRender(r.Context(), readmeURL) - if err != nil { - slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", err) + // Fetch raw markdown for editor pre-fill, then render + rawBytes, fetchErr := h.ReadmeFetcher.FetchRaw(r.Context(), readmeURL) + if fetchErr != nil { + slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", fetchErr) } else { - readmeHTML = template.HTML(html) + rawDescription = string(rawBytes) + html, renderErr := h.ReadmeFetcher.RenderMarkdown(rawBytes) + if renderErr != nil { + slog.Debug("Failed to render fetched README", "url", readmeURL, "error", renderErr) + } else { + readmeHTML = template.HTML(html) + } } } } @@ -181,28 +190,30 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request data := struct { PageData - Meta *PageMeta - Owner *db.User - Repository *db.Repository - LatestTag string - StarCount int - PullCount int - IsStarred bool - IsOwner bool - ReadmeHTML template.HTML - ArtifactType string + Meta *PageMeta + Owner *db.User + Repository *db.Repository + LatestTag string + StarCount int + PullCount int + IsStarred bool + IsOwner bool + ReadmeHTML template.HTML + RawDescription string + ArtifactType string }{ - PageData: NewPageData(r, &h.BaseUIHandler), - Meta: meta, - Owner: owner, - Repository: repo, - LatestTag: latestTagName, - StarCount: stats.StarCount, - PullCount: stats.PullCount, - IsStarred: isStarred, - IsOwner: isOwner, - ReadmeHTML: readmeHTML, - ArtifactType: artifactType, + PageData: NewPageData(r, &h.BaseUIHandler), + Meta: meta, + Owner: owner, + Repository: repo, + LatestTag: latestTagName, + StarCount: stats.StarCount, + PullCount: stats.PullCount, + IsStarred: isStarred, + IsOwner: isOwner, + ReadmeHTML: readmeHTML, + RawDescription: rawDescription, + ArtifactType: artifactType, } if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil { diff --git a/pkg/appview/jetstream/backfill.go b/pkg/appview/jetstream/backfill.go index 9403c65..1c4b03f 100644 --- a/pkg/appview/jetstream/backfill.go +++ b/pkg/appview/jetstream/backfill.go @@ -635,8 +635,8 @@ func (b *BackfillWorker) reconcileRepoPageDescriptions(ctx context.Context, did, } for _, page := range repoPages { - // Skip pages that already have a description - if page.Description != "" { + // Skip pages that were manually edited by the user or already have a description + if page.UserEdited || page.Description != "" { continue } @@ -668,7 +668,7 @@ func (b *BackfillWorker) reconcileRepoPageDescriptions(ctx context.Context, did, } // Always update database with the fetched content - if err := db.UpsertRepoPage(b.db, did, page.Repository, description, page.AvatarCID, page.CreatedAt, time.Now()); err != nil { + if err := db.UpsertRepoPage(b.db, did, page.Repository, description, page.AvatarCID, false, page.CreatedAt, time.Now()); err != nil { slog.Warn("Failed to update repo page in database", "did", did, "repository", page.Repository, "error", err) } else if !pdsUpdated { slog.Info("Updated repo page in database (PDS not updated)", "did", did, "repository", page.Repository) diff --git a/pkg/appview/jetstream/processor.go b/pkg/appview/jetstream/processor.go index 9ce49bf..c169169 100644 --- a/pkg/appview/jetstream/processor.go +++ b/pkg/appview/jetstream/processor.go @@ -560,7 +560,7 @@ func (p *Processor) ProcessRepoPage(ctx context.Context, did string, rkey string } // Upsert to database - return db.UpsertRepoPage(p.db, did, pageRecord.Repository, pageRecord.Description, avatarCID, pageRecord.CreatedAt, pageRecord.UpdatedAt) + return db.UpsertRepoPage(p.db, did, pageRecord.Repository, pageRecord.Description, avatarCID, pageRecord.UserEdited, pageRecord.CreatedAt, pageRecord.UpdatedAt) } // ProcessIdentity handles identity change events (handle updates) diff --git a/pkg/appview/public/icons.svg b/pkg/appview/public/icons.svg index 63020e1..801b292 100644 --- a/pkg/appview/public/icons.svg +++ b/pkg/appview/public/icons.svg @@ -6,12 +6,14 @@No description available
+ {{ end }}No description available
+ + + {{ if .IsOwner }} + + + {{ end }}