a love letter to tangled (android, iOS, and a search API)
19
fork

Configure Feed

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

feat: update repo detail page with issue and PR selection

* add issue and pull request comment

* URI parsing utilities

* add unit tests for normalizers and URI helpers

+1352 -137
+4 -4
docs/tasks/phase-2.md
··· 43 43 44 44 - [x] Fetch issues for a repo from PDS records (`listIssueRecords` + `listIssueStateRecords` from owner's PDS) 45 45 - [x] Display issue list with state filter (open/closed) 46 - - [ ] Issue detail view: title, body, author, state 47 - - [ ] Issue comments: fetch `sh.tangled.repo.issue.comment` records, render threaded 46 + - [x] Issue detail view: title, body, author, state 47 + - [x] Issue comments: fetch `sh.tangled.repo.issue.comment` records, render threaded 48 48 49 49 ## Pull Requests (read-only) 50 50 51 51 - [x] Fetch PRs for a repo from PDS records (`listPullRecords` + `listPullStatusRecords` from owner's PDS) 52 52 - [x] Display PR list with status filter (open/closed/merged) 53 - - [ ] PR detail view: title, body, author, source/target branches 54 - - [ ] PR comments: fetch `sh.tangled.repo.pull.comment` records 53 + - [x] PR detail view: title, body, author, source/target branches 54 + - [x] PR comments: fetch `sh.tangled.repo.pull.comment` records 55 55 56 56 ## Caching 57 57
+24
src/app/router/index.ts
··· 12 12 { path: "", redirect: "/tabs/home" }, 13 13 { path: "home", component: () => import("@/features/home/HomePage.vue") }, 14 14 { path: "home/repo/:owner/:repo", component: () => import("@/features/repo/RepoDetailPage.vue") }, 15 + { 16 + path: "home/repo/:owner/:repo/issues/:issueId", 17 + component: () => import("@/features/repo/IssueDetailPage.vue"), 18 + }, 19 + { 20 + path: "home/repo/:owner/:repo/pulls/:pullId", 21 + component: () => import("@/features/repo/PullRequestDetailPage.vue"), 22 + }, 15 23 { path: "home/user/:handle", component: () => import("@/features/profile/UserProfilePage.vue") }, 16 24 { path: "explore", component: () => import("@/features/explore/ExplorePage.vue") }, 17 25 { path: "explore/repo/:owner/:repo", component: () => import("@/features/repo/RepoDetailPage.vue") }, 26 + { 27 + path: "explore/repo/:owner/:repo/issues/:issueId", 28 + component: () => import("@/features/repo/IssueDetailPage.vue"), 29 + }, 30 + { 31 + path: "explore/repo/:owner/:repo/pulls/:pullId", 32 + component: () => import("@/features/repo/PullRequestDetailPage.vue"), 33 + }, 18 34 { path: "explore/user/:handle", component: () => import("@/features/profile/UserProfilePage.vue") }, 19 35 { path: "activity", component: () => import("@/features/activity/ActivityPage.vue") }, 20 36 { path: "activity/repo/:owner/:repo", component: () => import("@/features/repo/RepoDetailPage.vue") }, 37 + { 38 + path: "activity/repo/:owner/:repo/issues/:issueId", 39 + component: () => import("@/features/repo/IssueDetailPage.vue"), 40 + }, 41 + { 42 + path: "activity/repo/:owner/:repo/pulls/:pullId", 43 + component: () => import("@/features/repo/PullRequestDetailPage.vue"), 44 + }, 21 45 { path: "activity/user/:handle", component: () => import("@/features/profile/UserProfilePage.vue") }, 22 46 { path: "profile", component: () => import("@/features/profile/ProfilePage.vue") }, 23 47 ],
+98
src/components/repo/CommentThread.vue
··· 1 + <template> 2 + <div class="comment-thread"> 3 + <article v-for="comment in comments" :key="comment.atUri" class="comment-card" :style="commentStyle(comment.depth)"> 4 + <div class="comment-head"> 5 + <span class="author mono">{{ comment.authorHandle }}</span> 6 + <span class="dot">·</span> 7 + <span class="time">{{ relativeTime(comment.createdAt) }}</span> 8 + <span v-if="comment.depth > 0" class="reply-pill">Reply</span> 9 + </div> 10 + 11 + <pre class="comment-body">{{ comment.body }}</pre> 12 + </article> 13 + </div> 14 + </template> 15 + 16 + <script setup lang="ts"> 17 + import type { IssueComment, PullRequestComment } from "@/domain/models/comment.js"; 18 + 19 + defineProps<{ comments: Array<IssueComment | PullRequestComment> }>(); 20 + 21 + function relativeTime(iso: string): string { 22 + const timestamp = Date.parse(iso); 23 + if (Number.isNaN(timestamp)) return iso; 24 + 25 + const diff = Date.now() - timestamp; 26 + const minutes = Math.floor(diff / 60_000); 27 + const hours = Math.floor(minutes / 60); 28 + const days = Math.floor(hours / 24); 29 + 30 + if (days > 0) return `${days}d ago`; 31 + if (hours > 0) return `${hours}h ago`; 32 + if (minutes > 0) return `${minutes}m ago`; 33 + return "just now"; 34 + } 35 + 36 + function commentStyle(depth: number) { 37 + return { marginLeft: `${Math.min(depth, 4) * 16}px` }; 38 + } 39 + </script> 40 + 41 + <style scoped> 42 + .comment-thread { 43 + display: flex; 44 + flex-direction: column; 45 + gap: 12px; 46 + } 47 + 48 + .comment-card { 49 + background: var(--t-surface-raised); 50 + border: 1px solid var(--t-border); 51 + border-radius: var(--t-radius-md); 52 + padding: 12px 14px; 53 + } 54 + 55 + .comment-head { 56 + display: flex; 57 + align-items: center; 58 + gap: 6px; 59 + flex-wrap: wrap; 60 + margin-bottom: 8px; 61 + font-size: 12px; 62 + color: var(--t-text-muted); 63 + } 64 + 65 + .author { 66 + color: var(--t-accent); 67 + font-size: 11px; 68 + } 69 + 70 + .mono { 71 + font-family: var(--t-mono); 72 + } 73 + 74 + .dot { 75 + color: var(--t-border-strong); 76 + } 77 + 78 + .reply-pill { 79 + padding: 1px 6px; 80 + border-radius: 999px; 81 + background: var(--t-accent-dim); 82 + color: var(--t-accent); 83 + font-size: 10px; 84 + font-weight: 600; 85 + text-transform: uppercase; 86 + letter-spacing: 0.05em; 87 + } 88 + 89 + .comment-body { 90 + margin: 0; 91 + white-space: pre-wrap; 92 + word-break: break-word; 93 + font-family: inherit; 94 + font-size: 13px; 95 + line-height: 1.55; 96 + color: var(--t-text-secondary); 97 + } 98 + </style>
+15
src/domain/models/comment.ts
··· 1 + export type DiscussionComment = { 2 + atUri: string; 3 + rkey: string; 4 + body: string; 5 + authorDid: string; 6 + authorHandle: string; 7 + createdAt: string; 8 + mentions?: string[]; 9 + references?: string[]; 10 + depth: number; 11 + }; 12 + 13 + export type IssueComment = DiscussionComment & { issueAtUri: string; replyTo?: string }; 14 + 15 + export type PullRequestComment = DiscussionComment & { pullAtUri: string };
+4
src/domain/models/issue.ts
··· 2 2 3 3 export type IssueSummary = { 4 4 atUri: string; 5 + rkey: string; 6 + repoAtUri: string; 5 7 title: string; 6 8 authorDid: string; 7 9 authorHandle: string; ··· 9 11 createdAt: string; 10 12 commentCount?: number; 11 13 }; 14 + 15 + export type IssueDetail = IssueSummary & { body?: string; mentions?: string[]; references?: string[] };
+11
src/domain/models/pull-request.ts
··· 2 2 3 3 export type PullRequestSummary = { 4 4 atUri: string; 5 + rkey: string; 5 6 title: string; 6 7 authorDid: string; 7 8 authorHandle: string; ··· 9 10 createdAt: string; 10 11 updatedAt?: string; 11 12 sourceBranch: string; 13 + sourceRepoAtUri?: string; 14 + sourceSha?: string; 12 15 targetBranch: string; 16 + targetRepoAtUri: string; 13 17 roundCount?: number; 14 18 }; 19 + 20 + export type PullRequestDetail = PullRequestSummary & { 21 + body?: string; 22 + mentions?: string[]; 23 + references?: string[]; 24 + patch?: string; 25 + };
+265
src/features/repo/IssueDetailPage.vue
··· 1 + <template> 2 + <ion-page> 3 + <ion-header :translucent="true"> 4 + <ion-toolbar> 5 + <ion-buttons slot="start"> 6 + <ion-back-button :default-href="backHref" /> 7 + </ion-buttons> 8 + <ion-title class="detail-title">Issue #{{ issueId }}</ion-title> 9 + </ion-toolbar> 10 + </ion-header> 11 + 12 + <ion-content :fullscreen="true"> 13 + <template v-if="isLoading"> 14 + <SkeletonLoader variant="profile" /> 15 + <SkeletonLoader v-for="n in 2" :key="n" variant="card" /> 16 + </template> 17 + 18 + <EmptyState v-else-if="isError" :icon="alertCircleOutline" title="Could not load issue" :message="errorMessage" /> 19 + 20 + <EmptyState 21 + v-else-if="!issue" 22 + :icon="alertCircleOutline" 23 + title="Issue not found" 24 + message="This issue doesn't exist for the selected repository." /> 25 + 26 + <template v-else> 27 + <div class="detail-shell"> 28 + <section class="hero"> 29 + <div class="hero-top"> 30 + <ion-badge class="state-badge" :class="issue.state">{{ issue.state }}</ion-badge> 31 + <span class="mono issue-key">{{ owner }}/{{ repoName }}#{{ issue.rkey }}</span> 32 + </div> 33 + <h1 class="hero-title">{{ issue.title }}</h1> 34 + <div class="hero-meta"> 35 + <span class="mono meta-accent">{{ issue.authorHandle }}</span> 36 + <span class="sep">·</span> 37 + <span>{{ relativeTime(issue.createdAt) }}</span> 38 + <template v-if="issue.commentCount !== undefined"> 39 + <span class="sep">·</span> 40 + <ion-icon :icon="chatbubbleOutline" class="meta-icon" /> 41 + <span>{{ issue.commentCount }}</span> 42 + </template> 43 + </div> 44 + </section> 45 + 46 + <section class="section"> 47 + <div class="section-head"> 48 + <h2>Body</h2> 49 + </div> 50 + <MarkdownRenderer v-if="issue.body" :content="issue.body" /> 51 + <EmptyState 52 + v-else 53 + :icon="documentTextOutline" 54 + title="No description" 55 + message="This issue was opened without any body text." /> 56 + </section> 57 + 58 + <section class="section"> 59 + <div class="section-head"> 60 + <h2>Comments</h2> 61 + <span class="section-count">{{ comments.length }}</span> 62 + </div> 63 + <CommentThread v-if="comments.length" :comments="comments" /> 64 + <EmptyState 65 + v-else 66 + :icon="chatbubbleOutline" 67 + title="No comments" 68 + message="No discussion has been recorded for this issue yet." /> 69 + </section> 70 + </div> 71 + </template> 72 + </ion-content> 73 + </ion-page> 74 + </template> 75 + 76 + <script setup lang="ts"> 77 + import { computed } from "vue"; 78 + import { useRoute } from "vue-router"; 79 + import { 80 + IonPage, 81 + IonHeader, 82 + IonToolbar, 83 + IonTitle, 84 + IonContent, 85 + IonButtons, 86 + IonBackButton, 87 + IonBadge, 88 + IonIcon, 89 + } from "@ionic/vue"; 90 + import { alertCircleOutline, chatbubbleOutline, documentTextOutline } from "ionicons/icons"; 91 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 92 + import EmptyState from "@/components/common/EmptyState.vue"; 93 + import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 94 + import CommentThread from "@/components/repo/CommentThread.vue"; 95 + import { useIdentity, useRepoRecord, useIssueDetail, useIssueComments } from "@/services/tangled/queries.js"; 96 + 97 + const route = useRoute(); 98 + const owner = route.params.owner as string; 99 + const repoName = route.params.repo as string; 100 + const issueId = route.params.issueId as string; 101 + 102 + const identity = useIdentity(owner); 103 + const did = computed(() => identity.data.value?.did ?? ""); 104 + const pds = computed(() => identity.data.value?.pds ?? ""); 105 + const hasIdentity = computed(() => !!identity.data.value); 106 + 107 + const repoQuery = useRepoRecord(pds, did, repoName, owner, { enabled: hasIdentity }); 108 + const repoAtUri = computed(() => repoQuery.data.value?.atUri ?? ""); 109 + 110 + const issueQuery = useIssueDetail(pds, did, owner, issueId, { enabled: hasIdentity }); 111 + const issueAtUri = computed(() => issueQuery.data.value?.atUri ?? ""); 112 + const commentsQuery = useIssueComments(pds, did, owner, issueAtUri, { enabled: computed(() => !!issueAtUri.value) }); 113 + 114 + const issue = computed(() => { 115 + const value = issueQuery.data.value; 116 + if (!value) return undefined; 117 + if (repoAtUri.value && value.repoAtUri !== repoAtUri.value) return undefined; 118 + return value; 119 + }); 120 + 121 + const comments = computed(() => commentsQuery.data.value ?? []); 122 + const isLoading = computed( 123 + () => 124 + identity.isPending.value || 125 + repoQuery.isPending.value || 126 + issueQuery.isPending.value || 127 + commentsQuery.isPending.value, 128 + ); 129 + const isError = computed( 130 + () => identity.isError.value || repoQuery.isError.value || issueQuery.isError.value || commentsQuery.isError.value, 131 + ); 132 + const errorMessage = computed(() => { 133 + const error = identity.error.value ?? repoQuery.error.value ?? issueQuery.error.value ?? commentsQuery.error.value; 134 + return error instanceof Error ? error.message : "An unexpected error occurred."; 135 + }); 136 + 137 + const tabPrefix = computed(() => { 138 + if (route.path.startsWith("/tabs/explore")) return "/tabs/explore"; 139 + if (route.path.startsWith("/tabs/activity")) return "/tabs/activity"; 140 + return "/tabs/home"; 141 + }); 142 + 143 + const backHref = computed(() => `${tabPrefix.value}/repo/${owner}/${repoName}?tab=issues`); 144 + 145 + function relativeTime(iso: string): string { 146 + const timestamp = Date.parse(iso); 147 + if (Number.isNaN(timestamp)) return iso; 148 + 149 + const diff = Date.now() - timestamp; 150 + const minutes = Math.floor(diff / 60_000); 151 + const hours = Math.floor(minutes / 60); 152 + const days = Math.floor(hours / 24); 153 + 154 + if (days > 0) return `${days}d ago`; 155 + if (hours > 0) return `${hours}h ago`; 156 + if (minutes > 0) return `${minutes}m ago`; 157 + return "just now"; 158 + } 159 + </script> 160 + 161 + <style scoped> 162 + .detail-title { 163 + font-family: var(--t-mono); 164 + font-size: 14px; 165 + } 166 + 167 + .detail-shell { 168 + padding: 18px 16px 32px; 169 + } 170 + 171 + .hero { 172 + margin-bottom: 24px; 173 + } 174 + 175 + .hero-top { 176 + display: flex; 177 + align-items: center; 178 + gap: 10px; 179 + flex-wrap: wrap; 180 + margin-bottom: 10px; 181 + } 182 + 183 + .issue-key, 184 + .mono { 185 + font-family: var(--t-mono); 186 + } 187 + 188 + .issue-key { 189 + font-size: 11px; 190 + color: var(--t-text-muted); 191 + } 192 + 193 + .hero-title { 194 + margin: 0 0 8px; 195 + font-size: 22px; 196 + line-height: 1.2; 197 + color: var(--t-text-primary); 198 + } 199 + 200 + .hero-meta { 201 + display: flex; 202 + align-items: center; 203 + gap: 6px; 204 + flex-wrap: wrap; 205 + font-size: 13px; 206 + color: var(--t-text-muted); 207 + } 208 + 209 + .meta-accent { 210 + color: var(--t-accent); 211 + font-size: 12px; 212 + } 213 + 214 + .sep { 215 + color: var(--t-border-strong); 216 + } 217 + 218 + .meta-icon { 219 + font-size: 12px; 220 + } 221 + 222 + .section { 223 + margin-top: 20px; 224 + } 225 + 226 + .section-head { 227 + display: flex; 228 + align-items: center; 229 + justify-content: space-between; 230 + margin-bottom: 10px; 231 + } 232 + 233 + .section-head h2 { 234 + font-size: 11px; 235 + font-weight: 600; 236 + text-transform: uppercase; 237 + letter-spacing: 0.07em; 238 + color: var(--t-text-muted); 239 + margin: 0; 240 + } 241 + 242 + .section-count { 243 + font-size: 12px; 244 + color: var(--t-text-muted); 245 + } 246 + 247 + .state-badge { 248 + font-size: 11px; 249 + font-weight: 600; 250 + border-radius: 999px; 251 + padding: 3px 8px; 252 + text-transform: capitalize; 253 + } 254 + 255 + .state-badge.open { 256 + --background: var(--t-green-dim); 257 + --color: var(--t-green); 258 + } 259 + 260 + .state-badge.closed { 261 + --background: transparent; 262 + --color: var(--t-text-muted); 263 + border: 1px solid var(--t-border-strong); 264 + } 265 + </style>
+303
src/features/repo/PullRequestDetailPage.vue
··· 1 + <template> 2 + <ion-page> 3 + <ion-header :translucent="true"> 4 + <ion-toolbar> 5 + <ion-buttons slot="start"> 6 + <ion-back-button :default-href="backHref" /> 7 + </ion-buttons> 8 + <ion-title class="detail-title">PR #{{ pullId }}</ion-title> 9 + </ion-toolbar> 10 + </ion-header> 11 + 12 + <ion-content :fullscreen="true"> 13 + <template v-if="isLoading"> 14 + <SkeletonLoader variant="profile" /> 15 + <SkeletonLoader v-for="n in 2" :key="n" variant="card" /> 16 + </template> 17 + 18 + <EmptyState 19 + v-else-if="isError" 20 + :icon="alertCircleOutline" 21 + title="Could not load pull request" 22 + :message="errorMessage" /> 23 + 24 + <EmptyState 25 + v-else-if="!pullRequest" 26 + :icon="alertCircleOutline" 27 + title="Pull request not found" 28 + message="This pull request doesn't exist for the selected repository." /> 29 + 30 + <template v-else> 31 + <div class="detail-shell"> 32 + <section class="hero"> 33 + <div class="hero-top"> 34 + <ion-badge class="status-badge" :class="pullRequest.status">{{ pullRequest.status }}</ion-badge> 35 + <span class="mono pr-key">{{ owner }}/{{ repoName }}#{{ pullRequest.rkey }}</span> 36 + </div> 37 + <h1 class="hero-title">{{ pullRequest.title }}</h1> 38 + <div class="hero-meta"> 39 + <span class="mono meta-accent">{{ pullRequest.authorHandle }}</span> 40 + <span class="sep">·</span> 41 + <span>{{ relativeTime(pullRequest.createdAt) }}</span> 42 + <template v-if="pullRequest.roundCount !== undefined"> 43 + <span class="sep">·</span> 44 + <ion-icon :icon="chatbubbleOutline" class="meta-icon" /> 45 + <span>{{ pullRequest.roundCount }}</span> 46 + </template> 47 + </div> 48 + <div class="branch-row"> 49 + <span class="branch mono">{{ pullRequest.sourceBranch || "unknown" }}</span> 50 + <span class="branch-arrow">→</span> 51 + <span class="branch mono">{{ pullRequest.targetBranch }}</span> 52 + </div> 53 + </section> 54 + 55 + <section class="section"> 56 + <div class="section-head"> 57 + <h2>Body</h2> 58 + </div> 59 + <MarkdownRenderer v-if="pullRequest.body" :content="pullRequest.body" /> 60 + <EmptyState 61 + v-else 62 + :icon="documentTextOutline" 63 + title="No description" 64 + message="This pull request was opened without any body text." /> 65 + </section> 66 + 67 + <section class="section"> 68 + <div class="section-head"> 69 + <h2>Comments</h2> 70 + <span class="section-count">{{ comments.length }}</span> 71 + </div> 72 + <CommentThread v-if="comments.length" :comments="comments" /> 73 + <EmptyState 74 + v-else 75 + :icon="chatbubbleOutline" 76 + title="No comments" 77 + message="No discussion has been recorded for this pull request yet." /> 78 + </section> 79 + </div> 80 + </template> 81 + </ion-content> 82 + </ion-page> 83 + </template> 84 + 85 + <script setup lang="ts"> 86 + import { computed } from "vue"; 87 + import { useRoute } from "vue-router"; 88 + import { 89 + IonPage, 90 + IonHeader, 91 + IonToolbar, 92 + IonTitle, 93 + IonContent, 94 + IonButtons, 95 + IonBackButton, 96 + IonBadge, 97 + IonIcon, 98 + } from "@ionic/vue"; 99 + import { alertCircleOutline, chatbubbleOutline, documentTextOutline } from "ionicons/icons"; 100 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 101 + import EmptyState from "@/components/common/EmptyState.vue"; 102 + import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 103 + import CommentThread from "@/components/repo/CommentThread.vue"; 104 + import { 105 + useIdentity, 106 + useRepoRecord, 107 + usePullRequestDetail, 108 + usePullRequestComments, 109 + } from "@/services/tangled/queries.js"; 110 + 111 + const route = useRoute(); 112 + const owner = route.params.owner as string; 113 + const repoName = route.params.repo as string; 114 + const pullId = route.params.pullId as string; 115 + 116 + const identity = useIdentity(owner); 117 + const did = computed(() => identity.data.value?.did ?? ""); 118 + const pds = computed(() => identity.data.value?.pds ?? ""); 119 + const hasIdentity = computed(() => !!identity.data.value); 120 + 121 + const repoQuery = useRepoRecord(pds, did, repoName, owner, { enabled: hasIdentity }); 122 + const repoAtUri = computed(() => repoQuery.data.value?.atUri ?? ""); 123 + 124 + const pullQuery = usePullRequestDetail(pds, did, owner, pullId, { enabled: hasIdentity }); 125 + const pullAtUri = computed(() => pullQuery.data.value?.atUri ?? ""); 126 + const commentsQuery = usePullRequestComments(pds, did, owner, pullAtUri, { 127 + enabled: computed(() => !!pullAtUri.value), 128 + }); 129 + 130 + const pullRequest = computed(() => { 131 + const value = pullQuery.data.value; 132 + if (!value) return undefined; 133 + if (repoAtUri.value && value.targetRepoAtUri !== repoAtUri.value) return undefined; 134 + return value; 135 + }); 136 + 137 + const comments = computed(() => commentsQuery.data.value ?? []); 138 + const isLoading = computed( 139 + () => 140 + identity.isPending.value || repoQuery.isPending.value || pullQuery.isPending.value || commentsQuery.isPending.value, 141 + ); 142 + const isError = computed( 143 + () => identity.isError.value || repoQuery.isError.value || pullQuery.isError.value || commentsQuery.isError.value, 144 + ); 145 + const errorMessage = computed(() => { 146 + const error = identity.error.value ?? repoQuery.error.value ?? pullQuery.error.value ?? commentsQuery.error.value; 147 + return error instanceof Error ? error.message : "An unexpected error occurred."; 148 + }); 149 + 150 + const tabPrefix = computed(() => { 151 + if (route.path.startsWith("/tabs/explore")) return "/tabs/explore"; 152 + if (route.path.startsWith("/tabs/activity")) return "/tabs/activity"; 153 + return "/tabs/home"; 154 + }); 155 + 156 + const backHref = computed(() => `${tabPrefix.value}/repo/${owner}/${repoName}?tab=prs`); 157 + 158 + function relativeTime(iso: string): string { 159 + const timestamp = Date.parse(iso); 160 + if (Number.isNaN(timestamp)) return iso; 161 + 162 + const diff = Date.now() - timestamp; 163 + const minutes = Math.floor(diff / 60_000); 164 + const hours = Math.floor(minutes / 60); 165 + const days = Math.floor(hours / 24); 166 + 167 + if (days > 0) return `${days}d ago`; 168 + if (hours > 0) return `${hours}h ago`; 169 + if (minutes > 0) return `${minutes}m ago`; 170 + return "just now"; 171 + } 172 + </script> 173 + 174 + <style scoped> 175 + .detail-title { 176 + font-family: var(--t-mono); 177 + font-size: 14px; 178 + } 179 + 180 + .detail-shell { 181 + padding: 18px 16px 32px; 182 + } 183 + 184 + .hero { 185 + margin-bottom: 24px; 186 + } 187 + 188 + .hero-top { 189 + display: flex; 190 + align-items: center; 191 + gap: 10px; 192 + flex-wrap: wrap; 193 + margin-bottom: 10px; 194 + } 195 + 196 + .pr-key, 197 + .mono { 198 + font-family: var(--t-mono); 199 + } 200 + 201 + .pr-key { 202 + font-size: 11px; 203 + color: var(--t-text-muted); 204 + } 205 + 206 + .hero-title { 207 + margin: 0 0 8px; 208 + font-size: 22px; 209 + line-height: 1.2; 210 + color: var(--t-text-primary); 211 + } 212 + 213 + .hero-meta { 214 + display: flex; 215 + align-items: center; 216 + gap: 6px; 217 + flex-wrap: wrap; 218 + font-size: 13px; 219 + color: var(--t-text-muted); 220 + margin-bottom: 10px; 221 + } 222 + 223 + .meta-accent { 224 + color: var(--t-accent); 225 + font-size: 12px; 226 + } 227 + 228 + .sep { 229 + color: var(--t-border-strong); 230 + } 231 + 232 + .meta-icon { 233 + font-size: 12px; 234 + } 235 + 236 + .branch-row { 237 + display: inline-flex; 238 + align-items: center; 239 + gap: 8px; 240 + padding: 6px 10px; 241 + border-radius: 999px; 242 + background: var(--t-surface-raised); 243 + border: 1px solid var(--t-border); 244 + } 245 + 246 + .branch { 247 + font-size: 11px; 248 + color: var(--t-text-secondary); 249 + } 250 + 251 + .branch-arrow { 252 + color: var(--t-border-strong); 253 + } 254 + 255 + .section { 256 + margin-top: 20px; 257 + } 258 + 259 + .section-head { 260 + display: flex; 261 + align-items: center; 262 + justify-content: space-between; 263 + margin-bottom: 10px; 264 + } 265 + 266 + .section-head h2 { 267 + font-size: 11px; 268 + font-weight: 600; 269 + text-transform: uppercase; 270 + letter-spacing: 0.07em; 271 + color: var(--t-text-muted); 272 + margin: 0; 273 + } 274 + 275 + .section-count { 276 + font-size: 12px; 277 + color: var(--t-text-muted); 278 + } 279 + 280 + .status-badge { 281 + font-size: 11px; 282 + font-weight: 600; 283 + border-radius: 999px; 284 + padding: 3px 8px; 285 + text-transform: capitalize; 286 + } 287 + 288 + .status-badge.open { 289 + --background: var(--t-accent-dim); 290 + --color: var(--t-accent); 291 + } 292 + 293 + .status-badge.merged { 294 + --background: rgba(167, 139, 250, 0.1); 295 + --color: var(--t-purple); 296 + } 297 + 298 + .status-badge.closed { 299 + --background: transparent; 300 + --color: var(--t-text-muted); 301 + border: 1px solid var(--t-border-strong); 302 + } 303 + </style>
+52 -5
src/features/repo/RepoDetailPage.vue
··· 45 45 :knot-host="knotHost" 46 46 :knot-repo="knotRepo" 47 47 :branch="defaultBranch" /> 48 - <RepoIssues v-else-if="segment === 'issues'" :issues="issues" :is-loading="issuesQuery.isPending.value" /> 49 - <RepoPRs v-else-if="segment === 'prs'" :prs="prs" :is-loading="prsQuery.isPending.value" /> 48 + <RepoIssues 49 + v-else-if="segment === 'issues'" 50 + :issues="issues" 51 + :is-loading="issuesQuery.isPending.value" 52 + @select="openIssue" /> 53 + <RepoPRs 54 + v-else-if="segment === 'prs'" 55 + :prs="prs" 56 + :is-loading="prsQuery.isPending.value" 57 + @select="openPullRequest" /> 50 58 </template> 51 59 </ion-content> 52 60 </ion-page> 53 61 </template> 54 62 55 63 <script setup lang="ts"> 56 - import { ref, computed } from "vue"; 57 - import { useRoute } from "vue-router"; 64 + import { ref, computed, watch } from "vue"; 65 + import { useRoute, useRouter } from "vue-router"; 58 66 import { 59 67 IonPage, 60 68 IonHeader, ··· 87 95 import type { RepoDetail } from "@/domain/models/repo.js"; 88 96 89 97 const route = useRoute(); 98 + const router = useRouter(); 90 99 const owner = route.params.owner as string; 91 100 const repoName = route.params.repo as string; 92 101 93 - const segment = ref<"overview" | "files" | "issues" | "prs">("overview"); 102 + type Segment = "overview" | "files" | "issues" | "prs"; 103 + 104 + function segmentFromQuery(value: unknown): Segment { 105 + return value === "files" || value === "issues" || value === "prs" ? value : "overview"; 106 + } 107 + 108 + const segment = ref<Segment>("overview"); 109 + 110 + watch( 111 + () => route.query.tab, 112 + (value) => { 113 + segment.value = segmentFromQuery(value); 114 + }, 115 + { immediate: true }, 116 + ); 117 + 118 + watch(segment, (value) => { 119 + const nextQuery = { ...route.query }; 120 + if (value === "overview") { 121 + delete nextQuery.tab; 122 + } else { 123 + nextQuery.tab = value; 124 + } 125 + router.replace({ path: route.path, query: nextQuery }); 126 + }); 94 127 95 128 const identity = useIdentity(owner); 96 129 const did = computed(() => identity.data.value?.did ?? ""); ··· 133 166 const issues = computed(() => issuesQuery.data.value ?? []); 134 167 const prs = computed(() => prsQuery.data.value ?? []); 135 168 169 + const tabPrefix = computed(() => { 170 + if (route.path.startsWith("/tabs/explore")) return "/tabs/explore"; 171 + if (route.path.startsWith("/tabs/activity")) return "/tabs/activity"; 172 + return "/tabs/home"; 173 + }); 174 + 136 175 const isLoading = computed(() => identity.isPending.value || recordQuery.isPending.value); 137 176 const isError = computed(() => identity.isError.value || recordQuery.isError.value); 138 177 const errorMessage = computed(() => { 139 178 const err = identity.error.value ?? recordQuery.error.value; 140 179 return err instanceof Error ? err.message : "An unexpected error occurred."; 141 180 }); 181 + 182 + function openIssue(issue: { rkey: string }) { 183 + router.push(`${tabPrefix.value}/repo/${owner}/${repoName}/issues/${issue.rkey}?tab=issues`); 184 + } 185 + 186 + function openPullRequest(pr: { rkey: string }) { 187 + router.push(`${tabPrefix.value}/repo/${owner}/${repoName}/pulls/${pr.rkey}?tab=prs`); 188 + } 142 189 </script> 143 190 144 191 <style scoped>
+8 -1
src/features/repo/RepoIssues.vue
··· 18 18 </div> 19 19 20 20 <ion-list lines="inset" class="issue-list"> 21 - <ion-item v-for="issue in filtered" :key="issue.atUri" class="issue-item" button lines="inset"> 21 + <ion-item 22 + v-for="issue in filtered" 23 + :key="issue.atUri" 24 + class="issue-item" 25 + button 26 + lines="inset" 27 + @click="emit('select', issue)"> 22 28 <div slot="start" class="state-dot" :class="issue.state" /> 23 29 <ion-label class="issue-label"> 24 30 <span class="issue-title">{{ issue.title }}</span> ··· 56 62 import type { IssueSummary } from "@/domain/models/issue.js"; 57 63 58 64 const props = defineProps<{ issues: IssueSummary[]; isLoading?: boolean }>(); 65 + const emit = defineEmits<{ select: [issue: IssueSummary] }>(); 59 66 60 67 const filter = ref<"all" | "open" | "closed">("open"); 61 68
+8 -1
src/features/repo/RepoPRs.vue
··· 18 18 </div> 19 19 20 20 <ion-list lines="inset" class="pr-list"> 21 - <ion-item v-for="pr in filtered" :key="pr.atUri" class="pr-item" button lines="inset"> 21 + <ion-item 22 + v-for="pr in filtered" 23 + :key="pr.atUri" 24 + class="pr-item" 25 + button 26 + lines="inset" 27 + @click="emit('select', pr)"> 22 28 <div slot="start" class="status-icon" :class="pr.status"> 23 29 <ion-icon :icon="gitMergeOutline" /> 24 30 </div> ··· 57 63 import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 58 64 59 65 const props = defineProps<{ prs: PullRequestSummary[]; isLoading?: boolean }>(); 66 + const emit = defineEmits<{ select: [pr: PullRequestSummary] }>(); 60 67 61 68 const filter = ref<"all" | "open" | "merged" | "closed">("open"); 62 69
+89 -63
src/mocks/issues.ts
··· 1 1 import type { IssueSummary } from "@/domain/models/issue.js"; 2 + import { getAtUriRkey } from "@/services/tangled/uris.js"; 3 + 4 + function issue(issue: Omit<IssueSummary, "rkey" | "repoAtUri">, repoAtUri: string): IssueSummary { 5 + return { ...issue, rkey: getAtUriRkey(issue.atUri), repoAtUri }; 6 + } 2 7 3 8 const MOCK_ISSUES: IssueSummary[] = [ 4 - { 5 - atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.issue/8", 6 - title: "Support streaming responses from XRPC subscriptions", 7 - authorDid: "did:plc:p2cp5gopk7mgjegy9waligxd", 8 - authorHandle: "desertthunder.dev", 9 - state: "open", 10 - createdAt: "2026-03-21T12:00:00Z", 11 - commentCount: 4, 12 - }, 13 - { 14 - atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.issue/7", 15 - title: "Keyboard navigation broken in record browser", 16 - authorDid: "did:plc:c3d4e5f6g7h8i9j0k1l2m3n4", 17 - authorHandle: "clara.bsky.social", 18 - state: "open", 19 - createdAt: "2026-03-19T17:30:00Z", 20 - commentCount: 2, 21 - }, 22 - { 23 - atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.issue/6", 24 - title: "Add pagination to lexicon list endpoint", 25 - authorDid: "did:plc:b2c3d4e5f6g7h8i9j0k1l2m3", 26 - authorHandle: "bob.tngl.sh", 27 - state: "open", 28 - createdAt: "2026-03-17T09:15:00Z", 29 - commentCount: 7, 30 - }, 31 - { 32 - atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.issue/5", 33 - title: "DID resolution fails when PLC directory is unreachable", 34 - authorDid: "did:plc:e5f6g7h8i9j0k1l2m3n4o5p6", 35 - authorHandle: "riku.tngl.sh", 36 - state: "closed", 37 - createdAt: "2026-03-10T11:00:00Z", 38 - commentCount: 5, 39 - }, 40 - { 41 - atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.issue/4", 42 - title: "Export TypeScript types for all lexicon schemas", 43 - authorDid: "did:plc:a1b2c3d4e5f6g7h8i9j0k1l2", 44 - authorHandle: "alice.tngl.sh", 45 - state: "closed", 46 - createdAt: "2026-03-06T14:20:00Z", 47 - commentCount: 3, 48 - }, 49 - { 50 - atUri: "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.issue/12", 51 - title: "Offline mode: cache last-viewed repos to IndexedDB", 52 - authorDid: "did:plc:p2cp5gopk7mgjegy9waligxd", 53 - authorHandle: "desertthunder.dev", 54 - state: "open", 55 - createdAt: "2026-03-22T08:40:00Z", 56 - commentCount: 0, 57 - }, 58 - { 59 - atUri: "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.issue/11", 60 - title: "Skeleton loaders flicker on fast connections", 61 - authorDid: "did:plc:c3d4e5f6g7h8i9j0k1l2m3n4", 62 - authorHandle: "clara.bsky.social", 63 - state: "open", 64 - createdAt: "2026-03-18T16:05:00Z", 65 - commentCount: 1, 66 - }, 9 + issue( 10 + { 11 + atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.issue/8", 12 + title: "Support streaming responses from XRPC subscriptions", 13 + authorDid: "did:plc:p2cp5gopk7mgjegy9waligxd", 14 + authorHandle: "desertthunder.dev", 15 + state: "open", 16 + createdAt: "2026-03-21T12:00:00Z", 17 + commentCount: 4, 18 + }, 19 + "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/atproto-explorer", 20 + ), 21 + issue( 22 + { 23 + atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.issue/7", 24 + title: "Keyboard navigation broken in record browser", 25 + authorDid: "did:plc:c3d4e5f6g7h8i9j0k1l2m3n4", 26 + authorHandle: "clara.bsky.social", 27 + state: "open", 28 + createdAt: "2026-03-19T17:30:00Z", 29 + commentCount: 2, 30 + }, 31 + "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/atproto-explorer", 32 + ), 33 + issue( 34 + { 35 + atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.issue/6", 36 + title: "Add pagination to lexicon list endpoint", 37 + authorDid: "did:plc:b2c3d4e5f6g7h8i9j0k1l2m3", 38 + authorHandle: "bob.tngl.sh", 39 + state: "open", 40 + createdAt: "2026-03-17T09:15:00Z", 41 + commentCount: 7, 42 + }, 43 + "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/atproto-explorer", 44 + ), 45 + issue( 46 + { 47 + atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.issue/5", 48 + title: "DID resolution fails when PLC directory is unreachable", 49 + authorDid: "did:plc:e5f6g7h8i9j0k1l2m3n4o5p6", 50 + authorHandle: "riku.tngl.sh", 51 + state: "closed", 52 + createdAt: "2026-03-10T11:00:00Z", 53 + commentCount: 5, 54 + }, 55 + "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/atproto-explorer", 56 + ), 57 + issue( 58 + { 59 + atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.issue/4", 60 + title: "Export TypeScript types for all lexicon schemas", 61 + authorDid: "did:plc:a1b2c3d4e5f6g7h8i9j0k1l2", 62 + authorHandle: "alice.tngl.sh", 63 + state: "closed", 64 + createdAt: "2026-03-06T14:20:00Z", 65 + commentCount: 3, 66 + }, 67 + "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/atproto-explorer", 68 + ), 69 + issue( 70 + { 71 + atUri: "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.issue/12", 72 + title: "Offline mode: cache last-viewed repos to IndexedDB", 73 + authorDid: "did:plc:p2cp5gopk7mgjegy9waligxd", 74 + authorHandle: "desertthunder.dev", 75 + state: "open", 76 + createdAt: "2026-03-22T08:40:00Z", 77 + commentCount: 0, 78 + }, 79 + "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.repo/twisted", 80 + ), 81 + issue( 82 + { 83 + atUri: "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.issue/11", 84 + title: "Skeleton loaders flicker on fast connections", 85 + authorDid: "did:plc:c3d4e5f6g7h8i9j0k1l2m3n4", 86 + authorHandle: "clara.bsky.social", 87 + state: "open", 88 + createdAt: "2026-03-18T16:05:00Z", 89 + commentCount: 1, 90 + }, 91 + "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.repo/twisted", 92 + ), 67 93 ]; 68 94 69 95 export function getMockIssues(): IssueSummary[] {
+83 -60
src/mocks/pull-requests.ts
··· 1 1 import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 2 + import { getAtUriRkey } from "@/services/tangled/uris.js"; 3 + 4 + function pullRequest( 5 + pullRequest: Omit<PullRequestSummary, "rkey" | "targetRepoAtUri">, 6 + targetRepoAtUri: string, 7 + ): PullRequestSummary { 8 + return { ...pullRequest, rkey: getAtUriRkey(pullRequest.atUri), targetRepoAtUri }; 9 + } 2 10 3 11 const MOCK_PRS: PullRequestSummary[] = [ 4 - { 5 - atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.pr/3", 6 - title: "feat: add dark mode support to token system", 7 - authorDid: "did:plc:c3d4e5f6g7h8i9j0k1l2m3n4", 8 - authorHandle: "clara.bsky.social", 9 - status: "open", 10 - createdAt: "2026-03-20T10:12:00Z", 11 - updatedAt: "2026-03-21T15:44:00Z", 12 - sourceBranch: "feat/dark-mode-tokens", 13 - targetBranch: "main", 14 - roundCount: 2, 15 - }, 16 - { 17 - atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.pr/2", 18 - title: "fix: resolve CORS issue with lexicon fetch", 19 - authorDid: "did:plc:b2c3d4e5f6g7h8i9j0k1l2m3", 20 - authorHandle: "bob.tngl.sh", 21 - status: "merged", 22 - createdAt: "2026-03-15T08:30:00Z", 23 - updatedAt: "2026-03-16T11:00:00Z", 24 - sourceBranch: "fix/cors-lexicon", 25 - targetBranch: "main", 26 - roundCount: 1, 27 - }, 28 - { 29 - atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.pr/1", 30 - title: "chore: upgrade @atcute/client to v4", 31 - authorDid: "did:plc:a1b2c3d4e5f6g7h8i9j0k1l2", 32 - authorHandle: "alice.tngl.sh", 33 - status: "merged", 34 - createdAt: "2026-03-08T14:00:00Z", 35 - updatedAt: "2026-03-09T09:20:00Z", 36 - sourceBranch: "chore/atcute-v4", 37 - targetBranch: "main", 38 - roundCount: 1, 39 - }, 40 - { 41 - atUri: "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.pr/5", 42 - title: "feat: tab routing with per-tab navigation stacks", 43 - authorDid: "did:plc:p2cp5gopk7mgjegy9waligxd", 44 - authorHandle: "desertthunder.dev", 45 - status: "open", 46 - createdAt: "2026-03-21T18:05:00Z", 47 - sourceBranch: "feat/tab-routing", 48 - targetBranch: "main", 49 - roundCount: 0, 50 - }, 51 - { 52 - atUri: "at://did:plc:b2c3d4e5f6g7h8i9j0k1l2m3/sh.tangled.pr/7", 53 - title: "refactor: replace manual flag parsing with cobra", 54 - authorDid: "did:plc:e5f6g7h8i9j0k1l2m3n4o5p6", 55 - authorHandle: "riku.tngl.sh", 56 - status: "closed", 57 - createdAt: "2026-03-12T09:45:00Z", 58 - updatedAt: "2026-03-13T12:30:00Z", 59 - sourceBranch: "refactor/cobra-cli", 60 - targetBranch: "main", 61 - roundCount: 3, 62 - }, 12 + pullRequest( 13 + { 14 + atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.pr/3", 15 + title: "feat: add dark mode support to token system", 16 + authorDid: "did:plc:c3d4e5f6g7h8i9j0k1l2m3n4", 17 + authorHandle: "clara.bsky.social", 18 + status: "open", 19 + createdAt: "2026-03-20T10:12:00Z", 20 + updatedAt: "2026-03-21T15:44:00Z", 21 + sourceBranch: "feat/dark-mode-tokens", 22 + targetBranch: "main", 23 + roundCount: 2, 24 + }, 25 + "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/atproto-explorer", 26 + ), 27 + pullRequest( 28 + { 29 + atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.pr/2", 30 + title: "fix: resolve CORS issue with lexicon fetch", 31 + authorDid: "did:plc:b2c3d4e5f6g7h8i9j0k1l2m3", 32 + authorHandle: "bob.tngl.sh", 33 + status: "merged", 34 + createdAt: "2026-03-15T08:30:00Z", 35 + updatedAt: "2026-03-16T11:00:00Z", 36 + sourceBranch: "fix/cors-lexicon", 37 + targetBranch: "main", 38 + roundCount: 1, 39 + }, 40 + "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/atproto-explorer", 41 + ), 42 + pullRequest( 43 + { 44 + atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.pr/1", 45 + title: "chore: upgrade @atcute/client to v4", 46 + authorDid: "did:plc:a1b2c3d4e5f6g7h8i9j0k1l2", 47 + authorHandle: "alice.tngl.sh", 48 + status: "merged", 49 + createdAt: "2026-03-08T14:00:00Z", 50 + updatedAt: "2026-03-09T09:20:00Z", 51 + sourceBranch: "chore/atcute-v4", 52 + targetBranch: "main", 53 + roundCount: 1, 54 + }, 55 + "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/atproto-explorer", 56 + ), 57 + pullRequest( 58 + { 59 + atUri: "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.pr/5", 60 + title: "feat: tab routing with per-tab navigation stacks", 61 + authorDid: "did:plc:p2cp5gopk7mgjegy9waligxd", 62 + authorHandle: "desertthunder.dev", 63 + status: "open", 64 + createdAt: "2026-03-21T18:05:00Z", 65 + sourceBranch: "feat/tab-routing", 66 + targetBranch: "main", 67 + roundCount: 0, 68 + }, 69 + "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.repo/twisted", 70 + ), 71 + pullRequest( 72 + { 73 + atUri: "at://did:plc:b2c3d4e5f6g7h8i9j0k1l2m3/sh.tangled.pr/7", 74 + title: "refactor: replace manual flag parsing with cobra", 75 + authorDid: "did:plc:e5f6g7h8i9j0k1l2m3n4o5p6", 76 + authorHandle: "riku.tngl.sh", 77 + status: "closed", 78 + createdAt: "2026-03-12T09:45:00Z", 79 + updatedAt: "2026-03-13T12:30:00Z", 80 + sourceBranch: "refactor/cobra-cli", 81 + targetBranch: "main", 82 + roundCount: 3, 83 + }, 84 + "at://did:plc:b2c3d4e5f6g7h8i9j0k1l2m3/sh.tangled.repo/git-log-pretty", 85 + ), 63 86 ]; 64 87 65 88 export function getMockPullRequests(repoAtUri?: string): PullRequestSummary[] { 66 89 if (repoAtUri) { 67 - return MOCK_PRS.filter((pr) => pr.atUri.startsWith(repoAtUri.replace("/sh.tangled.repo/", "/sh.tangled.pr/"))); 90 + return MOCK_PRS.filter((pr) => pr.targetRepoAtUri === repoAtUri); 68 91 } 69 92 return MOCK_PRS; 70 93 }
+38
src/services/tangled/endpoints.ts
··· 29 29 ShTangledRepo, 30 30 ShTangledActorProfile, 31 31 ShTangledRepoIssue, 32 + ShTangledRepoIssueComment, 32 33 ShTangledRepoIssueState, 33 34 ShTangledRepoPull, 35 + ShTangledRepoPullComment, 34 36 ShTangledRepoPullStatus, 35 37 } from "@atcute/tangled"; 36 38 import { throwOnXrpcError } from "@/services/atproto/client.js"; ··· 161 163 return getRecord<ShTangledRepo.Main>(pds, did, "sh.tangled.repo", repoName); 162 164 } 163 165 166 + export async function fetchIssueRecord( 167 + pds: string, 168 + did: string, 169 + rkey: string, 170 + ): Promise<GetRecordResponse<ShTangledRepoIssue.Main>> { 171 + return getRecord<ShTangledRepoIssue.Main>(pds, did, "sh.tangled.repo.issue", rkey); 172 + } 173 + 174 + export async function fetchPullRecord( 175 + pds: string, 176 + did: string, 177 + rkey: string, 178 + ): Promise<GetRecordResponse<ShTangledRepoPull.Main>> { 179 + return getRecord<ShTangledRepoPull.Main>(pds, did, "sh.tangled.repo.pull", rkey); 180 + } 181 + 164 182 /** 165 183 * Resolve an AT Protocol handle to a DID via bsky.social. 166 184 * Returns the DID string (e.g. "did:plc:xxx"). ··· 245 263 return listRecords<ShTangledRepoIssueState.Main>(pds, did, "sh.tangled.repo.issue.state", limit, cursor); 246 264 } 247 265 266 + /** List sh.tangled.repo.issue.comment records from a user's PDS. */ 267 + export async function listIssueCommentRecords( 268 + pds: string, 269 + did: string, 270 + limit = 100, 271 + cursor?: string, 272 + ): Promise<ListRecordsResponse<ShTangledRepoIssueComment.Main>> { 273 + return listRecords<ShTangledRepoIssueComment.Main>(pds, did, "sh.tangled.repo.issue.comment", limit, cursor); 274 + } 275 + 248 276 /** List sh.tangled.repo.pull records from a user's PDS. */ 249 277 export async function listPullRecords( 250 278 pds: string, ··· 263 291 cursor?: string, 264 292 ): Promise<ListRecordsResponse<ShTangledRepoPullStatus.Main>> { 265 293 return listRecords<ShTangledRepoPullStatus.Main>(pds, did, "sh.tangled.repo.pull.status", limit, cursor); 294 + } 295 + 296 + /** List sh.tangled.repo.pull.comment records from a user's PDS. */ 297 + export async function listPullCommentRecords( 298 + pds: string, 299 + did: string, 300 + limit = 100, 301 + cursor?: string, 302 + ): Promise<ListRecordsResponse<ShTangledRepoPullComment.Main>> { 303 + return listRecords<ShTangledRepoPullComment.Main>(pds, did, "sh.tangled.repo.pull.comment", limit, cursor); 266 304 } 267 305 268 306 /**
+141 -3
src/services/tangled/normalizers.ts
··· 11 11 ShTangledRepo, 12 12 ShTangledActorProfile, 13 13 ShTangledRepoIssue, 14 + ShTangledRepoIssueComment, 14 15 ShTangledRepoPull, 16 + ShTangledRepoPullComment, 15 17 } from "@atcute/tangled"; 16 18 import type { RepoFile, RepoSummary, RepoDetail } from "@/domain/models/repo.js"; 17 19 import type { UserSummary } from "@/domain/models/user.js"; 18 - import type { IssueSummary } from "@/domain/models/issue.js"; 19 - import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 20 + import type { IssueSummary, IssueDetail } from "@/domain/models/issue.js"; 21 + import type { PullRequestSummary, PullRequestDetail } from "@/domain/models/pull-request.js"; 22 + import type { IssueComment, PullRequestComment } from "@/domain/models/comment.js"; 23 + import { getAtUriRkey } from "./uris.js"; 20 24 21 25 function modeToFileKind(mode: string): RepoFile["type"] { 22 26 if (mode.startsWith("04")) return "dir"; ··· 188 192 authorHandle: string, 189 193 state: "open" | "closed" = "open", 190 194 ): IssueSummary { 191 - return { atUri, title: record.title, authorDid, authorHandle, state, createdAt: record.createdAt }; 195 + return { 196 + atUri, 197 + rkey: getAtUriRkey(atUri), 198 + repoAtUri: record.repo, 199 + title: record.title, 200 + authorDid, 201 + authorHandle, 202 + state, 203 + createdAt: record.createdAt, 204 + }; 205 + } 206 + 207 + export function normalizeIssueDetail( 208 + record: ShTangledRepoIssue.Main, 209 + atUri: string, 210 + authorDid: string, 211 + authorHandle: string, 212 + state: "open" | "closed" = "open", 213 + commentCount = 0, 214 + ): IssueDetail { 215 + return { 216 + ...normalizeIssueRecord(record, atUri, authorDid, authorHandle, state), 217 + body: record.body, 218 + mentions: record.mentions, 219 + references: record.references, 220 + commentCount, 221 + }; 192 222 } 193 223 194 224 export function normalizePullRecord( ··· 200 230 ): PullRequestSummary { 201 231 return { 202 232 atUri, 233 + rkey: getAtUriRkey(atUri), 203 234 title: record.title, 204 235 authorDid, 205 236 authorHandle, 206 237 status, 207 238 createdAt: record.createdAt, 208 239 sourceBranch: record.source?.branch ?? "", 240 + sourceRepoAtUri: record.source?.repo, 241 + sourceSha: record.source?.sha, 209 242 targetBranch: record.target.branch, 243 + targetRepoAtUri: record.target.repo, 210 244 }; 245 + } 246 + 247 + export function normalizePullDetail( 248 + record: ShTangledRepoPull.Main, 249 + atUri: string, 250 + authorDid: string, 251 + authorHandle: string, 252 + status: "open" | "merged" | "closed" = "open", 253 + roundCount = 0, 254 + ): PullRequestDetail { 255 + return { 256 + ...normalizePullRecord(record, atUri, authorDid, authorHandle, status), 257 + body: record.body, 258 + mentions: record.mentions, 259 + references: record.references, 260 + patch: record.patch, 261 + roundCount, 262 + }; 263 + } 264 + 265 + export function normalizeIssueComment( 266 + record: ShTangledRepoIssueComment.Main, 267 + atUri: string, 268 + authorDid: string, 269 + authorHandle: string, 270 + ): IssueComment { 271 + return { 272 + atUri, 273 + rkey: getAtUriRkey(atUri), 274 + issueAtUri: record.issue, 275 + replyTo: record.replyTo, 276 + body: record.body, 277 + authorDid, 278 + authorHandle, 279 + createdAt: record.createdAt, 280 + mentions: record.mentions, 281 + references: record.references, 282 + depth: 0, 283 + }; 284 + } 285 + 286 + export function normalizePullComment( 287 + record: ShTangledRepoPullComment.Main, 288 + atUri: string, 289 + authorDid: string, 290 + authorHandle: string, 291 + ): PullRequestComment { 292 + return { 293 + atUri, 294 + rkey: getAtUriRkey(atUri), 295 + pullAtUri: record.pull, 296 + body: record.body, 297 + authorDid, 298 + authorHandle, 299 + createdAt: record.createdAt, 300 + mentions: record.mentions, 301 + references: record.references, 302 + depth: 0, 303 + }; 304 + } 305 + 306 + function compareByCreatedAt<T extends { createdAt: string; atUri: string }>(left: T, right: T): number { 307 + const leftTime = Date.parse(left.createdAt); 308 + const rightTime = Date.parse(right.createdAt); 309 + 310 + if (Number.isNaN(leftTime) || Number.isNaN(rightTime) || leftTime === rightTime) { 311 + return left.atUri.localeCompare(right.atUri); 312 + } 313 + 314 + return leftTime - rightTime; 315 + } 316 + 317 + export function buildIssueCommentThread(comments: IssueComment[]): IssueComment[] { 318 + const sorted = [...comments].sort(compareByCreatedAt); 319 + const knownUris = new Set(sorted.map((comment) => comment.atUri)); 320 + const byParent = new Map<string | undefined, IssueComment[]>(); 321 + 322 + for (const comment of sorted) { 323 + const parent = comment.replyTo && knownUris.has(comment.replyTo) ? comment.replyTo : undefined; 324 + const siblings = byParent.get(parent) ?? []; 325 + siblings.push(comment); 326 + byParent.set(parent, siblings); 327 + } 328 + 329 + const ordered: IssueComment[] = []; 330 + const seen = new Set<string>(); 331 + 332 + const visit = (parent: string | undefined, depth: number) => { 333 + const children = byParent.get(parent) ?? []; 334 + for (const comment of children) { 335 + if (seen.has(comment.atUri)) continue; 336 + seen.add(comment.atUri); 337 + ordered.push({ ...comment, depth }); 338 + visit(comment.atUri, depth + 1); 339 + } 340 + }; 341 + 342 + visit(undefined, 0); 343 + 344 + for (const comment of sorted) { 345 + if (!seen.has(comment.atUri)) ordered.push(comment); 346 + } 347 + 348 + return ordered; 211 349 } 212 350 213 351 export function normalizeActorProfile(
+125
src/services/tangled/queries.ts
··· 29 29 fetchRepoCompare, 30 30 fetchActorProfile, 31 31 fetchRepoRecord, 32 + fetchIssueRecord, 33 + fetchPullRecord, 32 34 listRepoRecords, 33 35 listIssueRecords, 36 + listIssueCommentRecords, 34 37 listIssueStateRecords, 35 38 listPullRecords, 39 + listPullCommentRecords, 36 40 listPullStatusRecords, 37 41 resolveHandle, 38 42 resolvePds, ··· 48 52 normalizeActorProfile, 49 53 normalizeRepoRecord, 50 54 normalizeIssueRecord, 55 + normalizeIssueDetail, 56 + normalizeIssueComment, 57 + buildIssueCommentThread, 51 58 normalizePullRecord, 59 + normalizePullDetail, 60 + normalizePullComment, 52 61 } from "./normalizers.js"; 53 62 54 63 export type { CommitEntry, BranchEntry, BlobContent, DefaultBranchInfo } from "./normalizers.js"; ··· 400 409 return pullsRes.records 401 410 .filter((r) => !targetRepo || r.value.target.repo === targetRepo) 402 411 .map((r) => normalizePullRecord(r.value, r.uri, toValue(did), toValue(handle), statusMap.get(r.uri) ?? "open")); 412 + }, 413 + enabled: options.enabled, 414 + staleTime: 2 * MIN, 415 + gcTime: 10 * MIN, 416 + }); 417 + } 418 + 419 + export function useIssueDetail( 420 + pds: MaybeRef<string>, 421 + did: MaybeRef<string>, 422 + handle: MaybeRef<string>, 423 + issueRkey: MaybeRef<string>, 424 + options: { enabled?: MaybeRef<boolean> } = {}, 425 + ) { 426 + return useQuery({ 427 + queryKey: computed(() => ["issueDetail", toValue(pds), toValue(did), toValue(issueRkey)]), 428 + queryFn: async () => { 429 + const [issueRes, statesRes, commentsRes] = await Promise.all([ 430 + fetchIssueRecord(toValue(pds), toValue(did), toValue(issueRkey)), 431 + listIssueStateRecords(toValue(pds), toValue(did)), 432 + listIssueCommentRecords(toValue(pds), toValue(did)), 433 + ]); 434 + 435 + const currentState = statesRes.records.reduce<"open" | "closed">((state, record) => { 436 + if (record.value.issue !== issueRes.uri) return state; 437 + return record.value.state === "sh.tangled.repo.issue.state.closed" ? "closed" : "open"; 438 + }, "open"); 439 + 440 + const commentCount = commentsRes.records.filter((record) => record.value.issue === issueRes.uri).length; 441 + 442 + return normalizeIssueDetail( 443 + issueRes.value, 444 + issueRes.uri, 445 + toValue(did), 446 + toValue(handle), 447 + currentState, 448 + commentCount, 449 + ); 450 + }, 451 + enabled: options.enabled, 452 + staleTime: 2 * MIN, 453 + gcTime: 10 * MIN, 454 + }); 455 + } 456 + 457 + export function useIssueComments( 458 + pds: MaybeRef<string>, 459 + did: MaybeRef<string>, 460 + handle: MaybeRef<string>, 461 + issueAtUri: MaybeRef<string>, 462 + options: { enabled?: MaybeRef<boolean> } = {}, 463 + ) { 464 + return useQuery({ 465 + queryKey: computed(() => ["issueComments", toValue(pds), toValue(did), toValue(issueAtUri)]), 466 + queryFn: async () => { 467 + const response = await listIssueCommentRecords(toValue(pds), toValue(did)); 468 + const comments = response.records 469 + .filter((record) => record.value.issue === toValue(issueAtUri)) 470 + .map((record) => normalizeIssueComment(record.value, record.uri, toValue(did), toValue(handle))); 471 + 472 + return buildIssueCommentThread(comments); 473 + }, 474 + enabled: options.enabled, 475 + staleTime: 2 * MIN, 476 + gcTime: 10 * MIN, 477 + }); 478 + } 479 + 480 + export function usePullRequestDetail( 481 + pds: MaybeRef<string>, 482 + did: MaybeRef<string>, 483 + handle: MaybeRef<string>, 484 + pullRkey: MaybeRef<string>, 485 + options: { enabled?: MaybeRef<boolean> } = {}, 486 + ) { 487 + return useQuery({ 488 + queryKey: computed(() => ["pullDetail", toValue(pds), toValue(did), toValue(pullRkey)]), 489 + queryFn: async () => { 490 + const [pullRes, statusesRes, commentsRes] = await Promise.all([ 491 + fetchPullRecord(toValue(pds), toValue(did), toValue(pullRkey)), 492 + listPullStatusRecords(toValue(pds), toValue(did)), 493 + listPullCommentRecords(toValue(pds), toValue(did)), 494 + ]); 495 + 496 + const currentStatus = statusesRes.records.reduce<"open" | "merged" | "closed">((status, record) => { 497 + if (record.value.pull !== pullRes.uri) return status; 498 + if (record.value.status === "sh.tangled.repo.pull.status.merged") return "merged"; 499 + if (record.value.status === "sh.tangled.repo.pull.status.closed") return "closed"; 500 + return "open"; 501 + }, "open"); 502 + 503 + const roundCount = commentsRes.records.filter((record) => record.value.pull === pullRes.uri).length; 504 + 505 + return normalizePullDetail(pullRes.value, pullRes.uri, toValue(did), toValue(handle), currentStatus, roundCount); 506 + }, 507 + enabled: options.enabled, 508 + staleTime: 2 * MIN, 509 + gcTime: 10 * MIN, 510 + }); 511 + } 512 + 513 + export function usePullRequestComments( 514 + pds: MaybeRef<string>, 515 + did: MaybeRef<string>, 516 + handle: MaybeRef<string>, 517 + pullAtUri: MaybeRef<string>, 518 + options: { enabled?: MaybeRef<boolean> } = {}, 519 + ) { 520 + return useQuery({ 521 + queryKey: computed(() => ["pullComments", toValue(pds), toValue(did), toValue(pullAtUri)]), 522 + queryFn: async () => { 523 + const response = await listPullCommentRecords(toValue(pds), toValue(did)); 524 + return response.records 525 + .filter((record) => record.value.pull === toValue(pullAtUri)) 526 + .map((record) => normalizePullComment(record.value, record.uri, toValue(did), toValue(handle))) 527 + .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt)); 403 528 }, 404 529 enabled: options.enabled, 405 530 staleTime: 2 * MIN,
+17
src/services/tangled/uris.ts
··· 1 + export type AtUriParts = { did: string; collection: string; rkey: string }; 2 + 3 + export function parseAtUri(atUri: string): AtUriParts | undefined { 4 + if (!atUri.startsWith("at://")) return undefined; 5 + 6 + const parts = atUri.slice("at://".length).split("/"); 7 + const [did, collection, ...rkeyParts] = parts; 8 + const rkey = rkeyParts.join("/"); 9 + 10 + if (!did || !collection || !rkey) return undefined; 11 + 12 + return { did, collection, rkey }; 13 + } 14 + 15 + export function getAtUriRkey(atUri: string): string { 16 + return parseAtUri(atUri)?.rkey ?? atUri; 17 + }
+67
tests/unit/tangled-normalizers.spec.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { buildIssueCommentThread } from "@/services/tangled/normalizers.js"; 3 + import { getAtUriRkey, parseAtUri } from "@/services/tangled/uris.js"; 4 + import type { IssueComment } from "@/domain/models/comment.js"; 5 + 6 + function makeComment(overrides: Partial<IssueComment> = {}): IssueComment { 7 + return { 8 + atUri: "at://did:plc:test/sh.tangled.repo.issue.comment/root", 9 + rkey: "root", 10 + issueAtUri: "at://did:plc:test/sh.tangled.repo.issue/123", 11 + body: "root", 12 + authorDid: "did:plc:test", 13 + authorHandle: "alice.test", 14 + createdAt: "2026-03-22T10:00:00Z", 15 + depth: 0, 16 + ...overrides, 17 + }; 18 + } 19 + 20 + describe("AT URI helpers", () => { 21 + it("extracts URI parts and rkey", () => { 22 + const uri = "at://did:plc:abc123/sh.tangled.repo.issue/42"; 23 + 24 + expect(parseAtUri(uri)).toEqual({ did: "did:plc:abc123", collection: "sh.tangled.repo.issue", rkey: "42" }); 25 + expect(getAtUriRkey(uri)).toBe("42"); 26 + }); 27 + }); 28 + 29 + describe("buildIssueCommentThread", () => { 30 + it("orders comments by parent-child relationship and assigns depth", () => { 31 + const root = makeComment(); 32 + const reply = makeComment({ 33 + atUri: "at://did:plc:test/sh.tangled.repo.issue.comment/reply", 34 + rkey: "reply", 35 + body: "reply", 36 + createdAt: "2026-03-22T10:01:00Z", 37 + replyTo: root.atUri, 38 + }); 39 + const secondRoot = makeComment({ 40 + atUri: "at://did:plc:test/sh.tangled.repo.issue.comment/second", 41 + rkey: "second", 42 + body: "second root", 43 + createdAt: "2026-03-22T10:02:00Z", 44 + }); 45 + 46 + const ordered = buildIssueCommentThread([secondRoot, reply, root]); 47 + 48 + expect(ordered.map((comment) => [comment.rkey, comment.depth])).toEqual([ 49 + ["root", 0], 50 + ["reply", 1], 51 + ["second", 0], 52 + ]); 53 + }); 54 + 55 + it("treats orphaned replies as top-level comments", () => { 56 + const orphan = makeComment({ 57 + atUri: "at://did:plc:test/sh.tangled.repo.issue.comment/orphan", 58 + rkey: "orphan", 59 + replyTo: "at://did:plc:test/sh.tangled.repo.issue.comment/missing", 60 + }); 61 + 62 + const ordered = buildIssueCommentThread([orphan]); 63 + 64 + expect(ordered).toHaveLength(1); 65 + expect(ordered[0]?.depth).toBe(0); 66 + }); 67 + });