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

feat: basic pages

+801 -192
+12 -12
docs/tasks/phase-2.md
··· 23 23 24 24 ## Repository Browsing 25 25 26 - - [ ] Wire `RepoDetailPage` to live repo data (metadata from PDS record + git data from knot) 27 - - [ ] Implement repo overview: description, topics, default branch, language breakdown 28 - - [ ] Implement README fetch: `sh.tangled.repo.blob` for `README.md` on default branch 29 - - [ ] Wire `MarkdownRenderer` to render real README content 30 - - [ ] Implement file tree: `sh.tangled.repo.tree` → navigate directories 31 - - [ ] Implement file viewer: `sh.tangled.repo.blob` → syntax-highlighted display 32 - - [ ] Implement commit log: `sh.tangled.repo.log` with cursor pagination 33 - - [ ] Implement branch list: `sh.tangled.repo.branches` 26 + - [x] Wire `RepoDetailPage` to live repo data (metadata from PDS record + git data from knot) 27 + - [x] Implement repo overview: description, topics, default branch, language breakdown 28 + - [x] Implement README fetch: `sh.tangled.repo.blob` for `README.md` on default branch 29 + - [x] Wire `MarkdownRenderer` to render real README content 30 + - [x] Implement file tree: `sh.tangled.repo.tree` → navigate directories 31 + - [x] Implement file viewer: `sh.tangled.repo.blob` → syntax-highlighted display 32 + - [x] Implement commit log: `sh.tangled.repo.log` with cursor pagination 33 + - [x] Implement branch list: `sh.tangled.repo.branches` 34 34 35 35 ## Profile Browsing 36 36 37 - - [ ] Fetch user profile from PDS: `com.atproto.repo.getRecord` for `sh.tangled.actor.profile` 38 - - [ ] Display profile: avatar (via `avatar.tangled.sh`), bio, links, location, pronouns, pinned repos 39 - - [ ] List user's repos: fetch `sh.tangled.repo` records from user's PDS 40 - - [ ] Wire `UserCard` component to real data 37 + - [x] Fetch user profile from PDS: `com.atproto.repo.getRecord` for `sh.tangled.actor.profile` 38 + - [x] Display profile: avatar (via `avatar.tangled.sh`), bio, links, location, pronouns, pinned repos 39 + - [x] List user's repos: fetch `sh.tangled.repo` records from user's PDS 40 + - [x] Wire `UserCard` component to real data 41 41 42 42 ## Issues (read-only) 43 43
+3
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 + { path: "home/user/:handle", component: () => import("@/features/profile/UserProfilePage.vue") }, 15 16 { path: "explore", component: () => import("@/features/explore/ExplorePage.vue") }, 16 17 { path: "explore/repo/:owner/:repo", component: () => import("@/features/repo/RepoDetailPage.vue") }, 18 + { path: "explore/user/:handle", component: () => import("@/features/profile/UserProfilePage.vue") }, 17 19 { path: "activity", component: () => import("@/features/activity/ActivityPage.vue") }, 18 20 { path: "activity/repo/:owner/:repo", component: () => import("@/features/repo/RepoDetailPage.vue") }, 21 + { path: "activity/user/:handle", component: () => import("@/features/profile/UserProfilePage.vue") }, 19 22 { path: "profile", component: () => import("@/features/profile/ProfilePage.vue") }, 20 23 ], 21 24 },
+3 -1
src/components/common/ActivityCard.vue
··· 27 27 alertCircleOutline, 28 28 closeCircleOutline, 29 29 } from "ionicons/icons"; 30 - import type { ActivityItem } from "@/domain/models/activity"; 30 + import type { ActivityItem } from "@/domain/models/activity.js"; 31 31 32 32 const props = defineProps<{ item: ActivityItem }>(); 33 33 const emit = defineEmits<{ click: [] }>(); ··· 103 103 font-size: 13px; 104 104 line-height: 1.45; 105 105 color: var(--t-text-secondary); 106 + display: inline-flex; 107 + gap: 4px; 106 108 } 107 109 108 110 .actor {
+1 -1
src/domain/models/repo.ts
··· 1 - import type { UserSummary } from "./user"; 1 + import type { UserSummary } from "./user.js"; 2 2 3 3 export type RepoSummary = { 4 4 atUri: string;
+3 -3
src/features/home/HomePage.vue
··· 47 47 import RepoCard from "@/components/common/RepoCard.vue"; 48 48 import ActivityCard from "@/components/common/ActivityCard.vue"; 49 49 import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 50 - import { getTrendingRepos } from "@/mocks/repos"; 51 - import { getMockActivity } from "@/mocks/activity"; 52 - import type { RepoSummary } from "@/domain/models/repo"; 50 + import { getTrendingRepos } from "@/mocks/repos.js"; 51 + import { getMockActivity } from "@/mocks/activity.js"; 52 + import type { RepoSummary } from "@/domain/models/repo.js"; 53 53 54 54 const router = useRouter(); 55 55 const loading = ref(true);
+289
src/features/profile/UserProfilePage.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="/tabs/home" /> 7 + </ion-buttons> 8 + <ion-title class="profile-title mono">{{ handle }}</ion-title> 9 + </ion-toolbar> 10 + </ion-header> 11 + 12 + <ion-content :fullscreen="true"> 13 + <!-- Loading --> 14 + <template v-if="isLoading"> 15 + <SkeletonLoader variant="profile" /> 16 + <SkeletonLoader v-for="n in 3" :key="n" variant="card" /> 17 + </template> 18 + 19 + <!-- Error --> 20 + <EmptyState 21 + v-else-if="isError" 22 + :icon="alertCircleOutline" 23 + title="Could not load profile" 24 + :message="errorMessage" /> 25 + 26 + <!-- Content --> 27 + <template v-else> 28 + <!-- Profile header --> 29 + <div class="profile-header"> 30 + <ion-avatar class="avatar"> 31 + <img 32 + v-if="profile" 33 + :src="`https://avatar.tangled.sh/${identity.data.value?.did}`" 34 + :alt="handle" 35 + @error="avatarError = true" /> 36 + <div v-if="!profile || avatarError" class="avatar-fallback" :style="{ background: avatarColor(handle) }"> 37 + {{ initials(handle) }} 38 + </div> 39 + </ion-avatar> 40 + 41 + <div class="profile-info"> 42 + <div class="profile-handle mono">{{ handle }}</div> 43 + <div v-if="profile?.displayName" class="profile-name">{{ profile.displayName }}</div> 44 + </div> 45 + </div> 46 + 47 + <div v-if="profile?.bio" class="profile-bio">{{ profile.bio }}</div> 48 + 49 + <!-- Meta: location, pronouns --> 50 + <div v-if="profile?.location || profile?.pronouns" class="profile-meta"> 51 + <span v-if="profile.location" class="meta-item"> 52 + <ion-icon :icon="locationOutline" class="meta-icon" /> 53 + {{ profile.location }} 54 + </span> 55 + <span v-if="profile.pronouns" class="meta-item"> 56 + <ion-icon :icon="personOutline" class="meta-icon" /> 57 + {{ profile.pronouns }} 58 + </span> 59 + </div> 60 + 61 + <!-- Links --> 62 + <div v-if="profile?.links?.length" class="profile-links"> 63 + <a 64 + v-for="link in profile.links" 65 + :key="link" 66 + :href="link" 67 + class="profile-link" 68 + target="_blank" 69 + rel="noopener noreferrer"> 70 + <ion-icon :icon="linkOutline" class="link-icon" /> 71 + {{ displayLink(link) }} 72 + </a> 73 + </div> 74 + 75 + <!-- Pinned repos --> 76 + <template v-if="pinnedRepos.length"> 77 + <h3 class="section-label">Pinned</h3> 78 + <RepoCard v-for="repo in pinnedRepos" :key="repo.atUri" :repo="repo" @click="navigateToRepo(repo)" /> 79 + </template> 80 + 81 + <!-- All repos --> 82 + <h3 class="section-label">Repositories</h3> 83 + <template v-if="reposQuery.isPending.value"> 84 + <SkeletonLoader v-for="n in 3" :key="n" variant="card" /> 85 + </template> 86 + <template v-else-if="repos.length"> 87 + <RepoCard v-for="repo in repos" :key="repo.atUri" :repo="repo" @click="navigateToRepo(repo)" /> 88 + </template> 89 + <EmptyState 90 + v-else 91 + :icon="codeSlashOutline" 92 + title="No repositories" 93 + message="This user hasn't created any repositories yet." /> 94 + </template> 95 + </ion-content> 96 + </ion-page> 97 + </template> 98 + 99 + <script setup lang="ts"> 100 + import { ref, computed } from "vue"; 101 + import { useRoute, useRouter } from "vue-router"; 102 + import { 103 + IonPage, 104 + IonHeader, 105 + IonToolbar, 106 + IonTitle, 107 + IonContent, 108 + IonButtons, 109 + IonBackButton, 110 + IonAvatar, 111 + IonIcon, 112 + } from "@ionic/vue"; 113 + import { alertCircleOutline, locationOutline, personOutline, linkOutline, codeSlashOutline } from "ionicons/icons"; 114 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 115 + import EmptyState from "@/components/common/EmptyState.vue"; 116 + import RepoCard from "@/components/common/RepoCard.vue"; 117 + import { useIdentity, useActorProfile, useUserRepos } from "@/services/tangled/queries.js"; 118 + import type { RepoSummary } from "@/domain/models/repo.js"; 119 + 120 + const route = useRoute(); 121 + const router = useRouter(); 122 + const handle = route.params.handle as string; 123 + 124 + const avatarError = ref(false); 125 + 126 + const identity = useIdentity(handle); 127 + const did = computed(() => identity.data.value?.did ?? ""); 128 + const pds = computed(() => identity.data.value?.pds ?? ""); 129 + const hasIdentity = computed(() => !!identity.data.value); 130 + 131 + const profileQuery = useActorProfile(pds, did, handle, undefined, { enabled: hasIdentity }); 132 + const reposQuery = useUserRepos(pds, did, handle, { enabled: hasIdentity }); 133 + 134 + const profile = computed(() => profileQuery.data.value); 135 + const repos = computed(() => reposQuery.data.value ?? []); 136 + 137 + const pinnedUris = computed(() => (profile.value as { pinnedRepos?: string[] } | undefined)?.pinnedRepos ?? []); 138 + const pinnedRepos = computed(() => repos.value.filter((r) => pinnedUris.value.includes(r.atUri))); 139 + 140 + const isLoading = computed(() => identity.isPending.value || profileQuery.isPending.value); 141 + const isError = computed(() => identity.isError.value || profileQuery.isError.value); 142 + const errorMessage = computed(() => { 143 + const err = identity.error.value ?? profileQuery.error.value; 144 + return err instanceof Error ? err.message : "An unexpected error occurred."; 145 + }); 146 + 147 + function navigateToRepo(repo: RepoSummary) { 148 + const tabPrefix = route.path.startsWith("/tabs/explore") 149 + ? "/tabs/explore" 150 + : route.path.startsWith("/tabs/activity") 151 + ? "/tabs/activity" 152 + : "/tabs/home"; 153 + router.push(`${tabPrefix}/repo/${repo.ownerHandle}/${repo.name}`); 154 + } 155 + 156 + function displayLink(url: string): string { 157 + try { 158 + return new URL(url).hostname; 159 + } catch { 160 + return url; 161 + } 162 + } 163 + 164 + const PALETTE = ["#22d3ee", "#a78bfa", "#34d399", "#fbbf24", "#f87171", "#fb923c", "#60a5fa"]; 165 + 166 + function avatarColor(h: string): string { 167 + let hash = 0; 168 + for (const ch of h) hash = (hash * 31 + ch.charCodeAt(0)) & 0xffffffff; 169 + return PALETTE[Math.abs(hash) % PALETTE.length]; 170 + } 171 + 172 + function initials(h: string): string { 173 + return h.split(".")[0].slice(0, 2).toUpperCase(); 174 + } 175 + </script> 176 + 177 + <style scoped> 178 + .profile-title { 179 + font-family: var(--t-mono); 180 + font-size: 14px; 181 + } 182 + 183 + .mono { 184 + font-family: var(--t-mono); 185 + } 186 + 187 + .profile-header { 188 + display: flex; 189 + align-items: center; 190 + gap: 16px; 191 + padding: 20px 16px 12px; 192 + } 193 + 194 + .avatar { 195 + width: 64px; 196 + height: 64px; 197 + flex-shrink: 0; 198 + border-radius: var(--t-radius-md); 199 + overflow: hidden; 200 + } 201 + 202 + .avatar-fallback { 203 + width: 100%; 204 + height: 100%; 205 + display: flex; 206 + align-items: center; 207 + justify-content: center; 208 + font-family: var(--t-mono); 209 + font-size: 18px; 210 + font-weight: 700; 211 + color: #0d1117; 212 + } 213 + 214 + .profile-info { 215 + flex: 1; 216 + min-width: 0; 217 + } 218 + 219 + .profile-handle { 220 + font-family: var(--t-mono); 221 + font-size: 15px; 222 + font-weight: 600; 223 + color: var(--t-accent); 224 + line-height: 1.3; 225 + } 226 + 227 + .profile-name { 228 + font-size: 14px; 229 + font-weight: 500; 230 + color: var(--t-text-primary); 231 + margin-top: 2px; 232 + } 233 + 234 + .profile-bio { 235 + font-size: 14px; 236 + color: var(--t-text-secondary); 237 + line-height: 1.55; 238 + padding: 0 16px 12px; 239 + } 240 + 241 + .profile-meta { 242 + display: flex; 243 + flex-wrap: wrap; 244 + gap: 12px; 245 + padding: 0 16px 12px; 246 + } 247 + 248 + .meta-item { 249 + display: flex; 250 + align-items: center; 251 + gap: 5px; 252 + font-size: 13px; 253 + color: var(--t-text-muted); 254 + } 255 + 256 + .meta-icon { 257 + font-size: 14px; 258 + } 259 + 260 + .profile-links { 261 + display: flex; 262 + flex-direction: column; 263 + gap: 6px; 264 + padding: 0 16px 14px; 265 + } 266 + 267 + .profile-link { 268 + display: flex; 269 + align-items: center; 270 + gap: 6px; 271 + font-size: 13px; 272 + color: var(--t-accent); 273 + text-decoration: none; 274 + } 275 + 276 + .link-icon { 277 + font-size: 14px; 278 + flex-shrink: 0; 279 + } 280 + 281 + .section-label { 282 + font-size: 11px; 283 + font-weight: 600; 284 + text-transform: uppercase; 285 + letter-spacing: 0.07em; 286 + color: var(--t-text-muted); 287 + margin: 16px 16px 8px; 288 + } 289 + </style>
+82 -39
src/features/repo/RepoDetailPage.vue
··· 21 21 22 22 <ion-content :fullscreen="true"> 23 23 <!-- Loading skeleton --> 24 - <template v-if="loading"> 24 + <template v-if="isLoading"> 25 25 <SkeletonLoader variant="profile" /> 26 26 <SkeletonLoader v-for="n in 3" :key="n" variant="card" /> 27 27 </template> 28 28 29 + <!-- Error --> 30 + <EmptyState v-else-if="isError" :icon="alertCircleOutline" title="Could not load repo" :message="errorMessage" /> 31 + 29 32 <!-- Not found --> 30 33 <EmptyState 31 34 v-else-if="!repo" 32 35 :icon="alertCircleOutline" 33 36 title="Repo not found" 34 - message="This repository doesn't exist or hasn't been loaded yet." 35 - /> 37 + message="This repository doesn't exist or hasn't been loaded yet." /> 36 38 37 39 <!-- Content --> 38 40 <template v-else> 39 - <RepoOverview v-if="segment === 'overview'" :repo="repo" /> 40 - <RepoFiles v-else-if="segment === 'files'" :files="files" /> 41 - <RepoIssues v-else-if="segment === 'issues'" :issues="issues" /> 42 - <RepoPRs v-else-if="segment === 'prs'" :prs="prs" /> 41 + <RepoOverview v-if="segment === 'overview'" :repo="repo" :commits="commits" /> 42 + <RepoFiles 43 + v-else-if="segment === 'files'" 44 + :files="files" 45 + :knot-host="knotHost" 46 + :knot-repo="knotRepo" 47 + :branch="defaultBranch" /> 48 + <RepoIssues v-else-if="segment === 'issues'" :issues="[]" /> 49 + <RepoPRs v-else-if="segment === 'prs'" :prs="[]" /> 43 50 </template> 44 51 </ion-content> 45 52 </ion-page> 46 53 </template> 47 54 48 55 <script setup lang="ts"> 49 - import { ref, onMounted } from 'vue'; 50 - import { useRoute } from 'vue-router'; 56 + import { ref, computed } from "vue"; 57 + import { useRoute } from "vue-router"; 58 + import { 59 + IonPage, 60 + IonHeader, 61 + IonToolbar, 62 + IonTitle, 63 + IonContent, 64 + IonButtons, 65 + IonBackButton, 66 + IonSegment, 67 + IonSegmentButton, 68 + } from "@ionic/vue"; 69 + import { alertCircleOutline } from "ionicons/icons"; 70 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 71 + import EmptyState from "@/components/common/EmptyState.vue"; 72 + import RepoOverview from "./RepoOverview.vue"; 73 + import RepoFiles from "./RepoFiles.vue"; 74 + import RepoIssues from "./RepoIssues.vue"; 75 + import RepoPRs from "./RepoPRs.vue"; 51 76 import { 52 - IonPage, IonHeader, IonToolbar, IonTitle, IonContent, 53 - IonButtons, IonBackButton, IonSegment, IonSegmentButton, 54 - } from '@ionic/vue'; 55 - import { alertCircleOutline } from 'ionicons/icons'; 56 - import SkeletonLoader from '@/components/common/SkeletonLoader.vue'; 57 - import EmptyState from '@/components/common/EmptyState.vue'; 58 - import RepoOverview from './RepoOverview.vue'; 59 - import RepoFiles from './RepoFiles.vue'; 60 - import RepoIssues from './RepoIssues.vue'; 61 - import RepoPRs from './RepoPRs.vue'; 62 - import { getMockRepoDetail, getMockRepoFiles } from '@/mocks/repos'; 63 - import { getMockIssues } from '@/mocks/issues'; 64 - import { getMockPullRequests } from '@/mocks/pull-requests'; 65 - import type { RepoDetail, RepoFile } from '@/domain/models/repo'; 66 - import type { IssueSummary } from '@/domain/models/issue'; 67 - import type { PullRequestSummary } from '@/domain/models/pull-request'; 77 + useIdentity, 78 + useRepoRecord, 79 + useDefaultBranch, 80 + useRepoTree, 81 + useRepoBlob, 82 + useRepoLanguages, 83 + useRepoLog, 84 + } from "@/services/tangled/queries.js"; 85 + import type { RepoDetail } from "@/domain/models/repo.js"; 68 86 69 87 const route = useRoute(); 70 88 const owner = route.params.owner as string; 71 89 const repoName = route.params.repo as string; 72 90 73 - const segment = ref<'overview' | 'files' | 'issues' | 'prs'>('overview'); 74 - const loading = ref(true); 75 - const repo = ref<RepoDetail | null>(null); 76 - const files = ref<RepoFile[]>([]); 77 - const issues = ref<IssueSummary[]>([]); 78 - const prs = ref<PullRequestSummary[]>([]); 91 + const segment = ref<"overview" | "files" | "issues" | "prs">("overview"); 79 92 80 - onMounted(() => { 81 - setTimeout(() => { 82 - repo.value = getMockRepoDetail(owner, repoName) ?? null; 83 - files.value = getMockRepoFiles(); 84 - issues.value = getMockIssues(); 85 - prs.value = getMockPullRequests(); 86 - loading.value = false; 87 - }, 400); 93 + const identity = useIdentity(owner); 94 + const did = computed(() => identity.data.value?.did ?? ""); 95 + const pds = computed(() => identity.data.value?.pds ?? ""); 96 + const hasIdentity = computed(() => !!identity.data.value); 97 + 98 + const recordQuery = useRepoRecord(pds, did, repoName, owner, { enabled: hasIdentity }); 99 + const knotHost = computed(() => recordQuery.data.value?.knot ?? ""); 100 + const knotRepo = computed(() => (did.value ? `${did.value}/${repoName}` : "")); 101 + const hasRecord = computed(() => !!recordQuery.data.value?.knot && !!did.value); 102 + 103 + const branchQuery = useDefaultBranch(knotHost, knotRepo, { enabled: hasRecord }); 104 + const defaultBranch = computed(() => branchQuery.data.value?.name ?? ""); 105 + const hasBranch = computed(() => !!branchQuery.data.value?.name); 106 + 107 + const treeQuery = useRepoTree(knotHost, knotRepo, defaultBranch, undefined, { enabled: hasBranch }); 108 + const languagesQuery = useRepoLanguages(knotHost, knotRepo, undefined, { enabled: hasBranch }); 109 + const readmeQuery = useRepoBlob(knotHost, knotRepo, defaultBranch, "README.md", { readme: true, enabled: hasBranch }); 110 + const logQuery = useRepoLog(knotHost, knotRepo, defaultBranch, { limit: 20, enabled: hasBranch }); 111 + 112 + const repo = computed((): RepoDetail | undefined => { 113 + const rec = recordQuery.data.value; 114 + if (!rec) return undefined; 115 + return { 116 + ...rec, 117 + defaultBranch: defaultBranch.value || undefined, 118 + languages: languagesQuery.data.value, 119 + readme: readmeQuery.data.value?.isBinary ? undefined : readmeQuery.data.value?.content, 120 + }; 121 + }); 122 + 123 + const files = computed(() => treeQuery.data.value ?? []); 124 + const commits = computed(() => logQuery.data.value ?? []); 125 + 126 + const isLoading = computed(() => identity.isPending.value || recordQuery.isPending.value); 127 + const isError = computed(() => identity.isError.value || recordQuery.isError.value); 128 + const errorMessage = computed(() => { 129 + const err = identity.error.value ?? recordQuery.error.value; 130 + return err instanceof Error ? err.message : "An unexpected error occurred."; 88 131 }); 89 132 </script> 90 133
+146 -21
src/features/repo/RepoFiles.vue
··· 1 1 <template> 2 2 <div class="files-view"> 3 - <ion-list lines="inset" class="file-list"> 4 - <FileTreeItem 5 - v-for="file in sortedFiles" 6 - :key="file.name" 7 - :file="file" 8 - lines="inset" 9 - @click="handleFileClick(file)" /> 10 - </ion-list> 3 + <!-- File viewer header --> 4 + <div v-if="selectedFile" class="viewer-header"> 5 + <ion-button fill="clear" size="small" class="back-btn" @click="selectedFile = null"> 6 + <ion-icon slot="start" :icon="arrowBackOutline" /> 7 + Files 8 + </ion-button> 9 + <span class="file-path mono">{{ selectedFile.path }}</span> 10 + </div> 11 11 12 - <EmptyState 13 - v-if="!files.length" 14 - :icon="folderOpenOutline" 15 - title="No files" 16 - message="This repository appears to be empty." /> 12 + <!-- File viewer --> 13 + <template v-if="selectedFile"> 14 + <template v-if="blobQuery.isPending.value"> 15 + <SkeletonLoader v-for="n in 6" :key="n" variant="list-item" /> 16 + </template> 17 + <EmptyState 18 + v-else-if="blobQuery.isError.value" 19 + :icon="alertCircleOutline" 20 + title="Could not load file" 21 + :message="blobQuery.error.value instanceof Error ? blobQuery.error.value.message : 'Unknown error'" /> 22 + <template v-else-if="blobQuery.data.value"> 23 + <div v-if="blobQuery.data.value.isBinary" class="binary-notice"> 24 + <ion-icon :icon="documentOutline" class="binary-icon" /> 25 + Binary file — cannot display. 26 + </div> 27 + <div v-else class="file-content-wrap"> 28 + <div class="file-meta"> 29 + <span class="file-size" v-if="blobQuery.data.value.size != null"> 30 + {{ formatSize(blobQuery.data.value.size) }} 31 + </span> 32 + </div> 33 + <pre class="file-content"><code>{{ blobQuery.data.value.content }}</code></pre> 34 + </div> 35 + </template> 36 + </template> 37 + 38 + <!-- File tree --> 39 + <template v-else> 40 + <ion-list lines="inset" class="file-list"> 41 + <FileTreeItem 42 + v-for="file in sortedFiles" 43 + :key="file.name" 44 + :file="file" 45 + @click="handleFileClick(file)" /> 46 + </ion-list> 47 + <EmptyState 48 + v-if="!files.length" 49 + :icon="folderOpenOutline" 50 + title="No files" 51 + message="This repository appears to be empty." /> 52 + </template> 17 53 </div> 18 54 </template> 19 55 20 56 <script setup lang="ts"> 21 - import { computed } from "vue"; 22 - import { IonList } from "@ionic/vue"; 23 - import { folderOpenOutline } from "ionicons/icons"; 57 + import { ref, computed } from "vue"; 58 + import { IonList, IonButton, IonIcon } from "@ionic/vue"; 59 + import { folderOpenOutline, alertCircleOutline, arrowBackOutline, documentOutline } from "ionicons/icons"; 24 60 import FileTreeItem from "@/components/repo/FileTreeItem.vue"; 25 61 import EmptyState from "@/components/common/EmptyState.vue"; 26 - import type { RepoFile } from "@/domain/models/repo"; 62 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 63 + import { useRepoBlob } from "@/services/tangled/queries.js"; 64 + import type { RepoFile } from "@/domain/models/repo.js"; 65 + 66 + const props = defineProps<{ 67 + files: RepoFile[]; 68 + knotHost: string; 69 + knotRepo: string; 70 + branch: string; 71 + }>(); 27 72 28 - const props = defineProps<{ files: RepoFile[] }>(); 73 + const selectedFile = ref<RepoFile | null>(null); 29 74 30 - /* Sort dirs first, then files, alphabetically within each group */ 31 75 const sortedFiles = computed(() => { 32 76 return [...props.files].sort((a, b) => { 33 77 if (a.type === b.type) return a.name.localeCompare(b.name); ··· 35 79 }); 36 80 }); 37 81 82 + const filePath = computed(() => selectedFile.value?.path ?? ""); 83 + const isFileSelected = computed(() => !!selectedFile.value && selectedFile.value.type === "file"); 84 + 85 + const blobQuery = useRepoBlob( 86 + computed(() => props.knotHost), 87 + computed(() => props.knotRepo), 88 + computed(() => props.branch), 89 + filePath, 90 + { enabled: isFileSelected }, 91 + ); 92 + 38 93 function handleFileClick(file: RepoFile) { 39 - // TODO: navigate into dir or open file viewer 40 - console.log("file clicked:", file.path, file.name); 94 + if (file.type === "dir") return; // TODO: navigate into directories 95 + selectedFile.value = file; 96 + } 97 + 98 + function formatSize(bytes: number): string { 99 + if (bytes < 1024) return `${bytes} B`; 100 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; 101 + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 41 102 } 42 103 </script> 43 104 ··· 49 110 .file-list { 50 111 background: transparent; 51 112 padding: 8px 0; 113 + } 114 + 115 + .viewer-header { 116 + display: flex; 117 + align-items: center; 118 + gap: 8px; 119 + padding: 8px 8px 4px; 120 + border-bottom: 1px solid var(--t-border); 121 + } 122 + 123 + .back-btn { 124 + --color: var(--t-accent); 125 + flex-shrink: 0; 126 + } 127 + 128 + .file-path { 129 + font-family: var(--t-mono); 130 + font-size: 12px; 131 + color: var(--t-text-secondary); 132 + overflow: hidden; 133 + text-overflow: ellipsis; 134 + white-space: nowrap; 135 + } 136 + 137 + .file-content-wrap { 138 + overflow: auto; 139 + } 140 + 141 + .file-meta { 142 + padding: 6px 16px; 143 + border-bottom: 1px solid var(--t-border); 144 + display: flex; 145 + justify-content: flex-end; 146 + } 147 + 148 + .file-size { 149 + font-size: 11px; 150 + color: var(--t-text-muted); 151 + font-family: var(--t-mono); 152 + } 153 + 154 + .file-content { 155 + margin: 0; 156 + padding: 16px; 157 + font-family: var(--t-mono); 158 + font-size: 12px; 159 + line-height: 1.6; 160 + color: var(--t-text-secondary); 161 + white-space: pre; 162 + overflow-x: auto; 163 + tab-size: 2; 164 + } 165 + 166 + .binary-notice { 167 + display: flex; 168 + align-items: center; 169 + gap: 8px; 170 + padding: 24px 16px; 171 + font-size: 14px; 172 + color: var(--t-text-muted); 173 + } 174 + 175 + .binary-icon { 176 + font-size: 20px; 52 177 } 53 178 </style>
+73 -2
src/features/repo/RepoOverview.vue
··· 46 46 <MarkdownRenderer v-if="repo.readme" :content="repo.readme" /> 47 47 <EmptyState v-else :icon="documentOutline" title="No README" message="This repo doesn't have a README yet." /> 48 48 </div> 49 + 50 + <!-- Recent Commits --> 51 + <div v-if="commits && commits.length" class="section"> 52 + <h3 class="section-label">Recent Commits</h3> 53 + <div class="commit-list"> 54 + <div v-for="commit in commits.slice(0, 10)" :key="commit.hash" class="commit-row"> 55 + <span class="commit-hash mono">{{ commit.shortHash ?? commit.hash.slice(0, 7) }}</span> 56 + <span class="commit-message">{{ commit.message }}</span> 57 + <span v-if="commit.when" class="commit-when">{{ relativeTime(commit.when) }}</span> 58 + </div> 59 + </div> 60 + </div> 49 61 </div> 50 62 </template> 51 63 ··· 55 67 import { starOutline, gitBranchOutline, codeOutline, documentOutline } from "ionicons/icons"; 56 68 import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 57 69 import EmptyState from "@/components/common/EmptyState.vue"; 58 - import type { RepoDetail } from "@/domain/models/repo"; 70 + import type { RepoDetail } from "@/domain/models/repo.js"; 71 + import type { CommitEntry } from "@/services/tangled/queries.js"; 59 72 60 - const props = defineProps<{ repo: RepoDetail }>(); 73 + const props = defineProps<{ repo: RepoDetail; commits?: CommitEntry[] }>(); 74 + 75 + function relativeTime(iso: string): string { 76 + const d = new Date(iso); 77 + if (isNaN(d.getTime())) return iso; 78 + const diff = Date.now() - d.getTime(); 79 + const m = Math.floor(diff / 60000); 80 + const h = Math.floor(m / 60); 81 + const days = Math.floor(h / 24); 82 + if (days > 0) return `${days}d ago`; 83 + if (h > 0) return `${h}h ago`; 84 + if (m > 0) return `${m}m ago`; 85 + return "just now"; 86 + } 61 87 62 88 const LANG_COLORS: Record<string, string> = { 63 89 TypeScript: "#3178c6", ··· 192 218 font-family: var(--t-mono); 193 219 font-size: 12px; 194 220 color: var(--t-text-muted); 221 + } 222 + 223 + .commit-list { 224 + display: flex; 225 + flex-direction: column; 226 + gap: 0; 227 + border: 1px solid var(--t-border); 228 + border-radius: var(--t-radius-md); 229 + margin: 0 16px; 230 + overflow: hidden; 231 + } 232 + 233 + .commit-row { 234 + display: flex; 235 + align-items: center; 236 + gap: 10px; 237 + padding: 8px 12px; 238 + border-bottom: 1px solid var(--t-border); 239 + } 240 + 241 + .commit-row:last-child { 242 + border-bottom: none; 243 + } 244 + 245 + .commit-hash { 246 + font-family: var(--t-mono); 247 + font-size: 11px; 248 + color: var(--t-accent); 249 + flex-shrink: 0; 250 + width: 52px; 251 + } 252 + 253 + .commit-message { 254 + font-size: 12px; 255 + color: var(--t-text-secondary); 256 + flex: 1; 257 + white-space: nowrap; 258 + overflow: hidden; 259 + text-overflow: ellipsis; 260 + } 261 + 262 + .commit-when { 263 + font-size: 11px; 264 + color: var(--t-text-muted); 265 + flex-shrink: 0; 195 266 } 196 267 </style>
+21 -30
src/main.ts
··· 1 - import { createApp } from 'vue' 2 - import App from './App.vue' 3 - import router from './app/router'; 1 + import { createApp } from "vue"; 2 + import App from "./App.vue"; 3 + import router from "@/app/router/index.js"; 4 4 5 - import { IonicVue } from '@ionic/vue'; 6 - import { createPinia } from 'pinia'; 7 - import { VueQueryPlugin } from '@tanstack/vue-query'; 8 - import { queryClient } from './core/query/client'; 5 + import { IonicVue } from "@ionic/vue"; 6 + import { createPinia } from "pinia"; 7 + import { VueQueryPlugin } from "@tanstack/vue-query"; 8 + import { queryClient } from "./core/query/client.js"; 9 9 10 - /* Core CSS required for Ionic components to work properly */ 11 - import '@ionic/vue/css/core.css'; 12 - 13 - /* Basic CSS for apps built with Ionic */ 14 - import '@ionic/vue/css/normalize.css'; 15 - import '@ionic/vue/css/structure.css'; 16 - import '@ionic/vue/css/typography.css'; 17 - 18 - /* Optional CSS utils that can be commented out */ 19 - import '@ionic/vue/css/padding.css'; 20 - import '@ionic/vue/css/float-elements.css'; 21 - import '@ionic/vue/css/text-alignment.css'; 22 - import '@ionic/vue/css/text-transformation.css'; 23 - import '@ionic/vue/css/flex-utils.css'; 24 - import '@ionic/vue/css/display.css'; 10 + import "@ionic/vue/css/core.css"; 11 + import "@ionic/vue/css/normalize.css"; 12 + import "@ionic/vue/css/structure.css"; 13 + import "@ionic/vue/css/typography.css"; 14 + import "@ionic/vue/css/padding.css"; 15 + import "@ionic/vue/css/float-elements.css"; 16 + import "@ionic/vue/css/text-alignment.css"; 17 + import "@ionic/vue/css/text-transformation.css"; 18 + import "@ionic/vue/css/flex-utils.css"; 19 + import "@ionic/vue/css/display.css"; 25 20 26 21 /** 27 22 * Ionic Dark Mode ··· 32 27 33 28 /* @import '@ionic/vue/css/palettes/dark.always.css'; */ 34 29 /* @import '@ionic/vue/css/palettes/dark.class.css'; */ 35 - import '@ionic/vue/css/palettes/dark.system.css'; 30 + import "@ionic/vue/css/palettes/dark.system.css"; 36 31 37 32 /* Theme variables */ 38 - import './theme/variables.css'; 33 + import "./theme/variables.css"; 39 34 40 - const app = createApp(App) 41 - .use(IonicVue) 42 - .use(router) 43 - .use(createPinia()) 44 - .use(VueQueryPlugin, { queryClient }); 35 + const app = createApp(App).use(IonicVue).use(router).use(createPinia()).use(VueQueryPlugin, { queryClient }); 45 36 46 37 router.isReady().then(() => { 47 - app.mount('#app'); 38 + app.mount("#app"); 48 39 });
+1 -1
src/mocks/activity.ts
··· 1 - import type { ActivityItem } from "@/domain/models/activity"; 1 + import type { ActivityItem } from "@/domain/models/activity.js"; 2 2 3 3 const MOCK_ACTIVITY: ActivityItem[] = [ 4 4 {
+1 -1
src/mocks/issues.ts
··· 1 - import type { IssueSummary } from "@/domain/models/issue"; 1 + import type { IssueSummary } from "@/domain/models/issue.js"; 2 2 3 3 const MOCK_ISSUES: IssueSummary[] = [ 4 4 {
+1 -1
src/mocks/pull-requests.ts
··· 1 - import type { PullRequestSummary } from "@/domain/models/pull-request"; 1 + import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 2 2 3 3 const MOCK_PRS: PullRequestSummary[] = [ 4 4 {
+1 -1
src/mocks/repos.ts
··· 1 - import type { RepoSummary, RepoDetail, RepoFile } from "@/domain/models/repo"; 1 + import type { RepoSummary, RepoDetail, RepoFile } from "@/domain/models/repo.js"; 2 2 3 3 const MOCK_REPOS: RepoSummary[] = [ 4 4 {
+51 -51
src/mocks/users.ts
··· 1 - import type { UserSummary } from '@/domain/models/user'; 1 + import type { UserSummary } from "@/domain/models/user.js"; 2 2 3 3 const MOCK_USERS: UserSummary[] = [ 4 - { 5 - did: 'did:plc:p2cp5gopk7mgjegy9waligxd', 6 - handle: 'desertthunder.dev', 7 - displayName: 'Desert Thunder', 8 - bio: 'Building things on the AT Protocol. Open source enthusiast.', 9 - followerCount: 142, 10 - followingCount: 87, 11 - }, 12 - { 13 - did: 'did:plc:a1b2c3d4e5f6g7h8i9j0k1l2', 14 - handle: 'alice.tngl.sh', 15 - displayName: 'Alice Chen', 16 - bio: 'Distributed systems @ Tangled. TypeScript, Go, Rust.', 17 - followerCount: 891, 18 - followingCount: 234, 19 - }, 20 - { 21 - did: 'did:plc:b2c3d4e5f6g7h8i9j0k1l2m3', 22 - handle: 'bob.tngl.sh', 23 - displayName: 'Bob Nakamura', 24 - bio: 'Open source contributor. Loves compilers and weird edge cases.', 25 - followerCount: 307, 26 - followingCount: 412, 27 - }, 28 - { 29 - did: 'did:plc:c3d4e5f6g7h8i9j0k1l2m3n4', 30 - handle: 'clara.bsky.social', 31 - displayName: 'Clara Osei', 32 - bio: 'Frontend dev. Making the decentralized web feel fast.', 33 - followerCount: 554, 34 - followingCount: 198, 35 - }, 36 - { 37 - did: 'did:plc:d4e5f6g7h8i9j0k1l2m3n4o5', 38 - handle: 'dev.tangled.sh', 39 - displayName: 'Tangled Dev', 40 - bio: 'Official Tangled development account.', 41 - followerCount: 4210, 42 - followingCount: 12, 43 - }, 44 - { 45 - did: 'did:plc:e5f6g7h8i9j0k1l2m3n4o5p6', 46 - handle: 'riku.tngl.sh', 47 - displayName: 'Riku Mäkinen', 48 - bio: 'Systems programmer. NixOS, Git internals, coffee.', 49 - followerCount: 228, 50 - followingCount: 315, 51 - }, 4 + { 5 + did: "did:plc:p2cp5gopk7mgjegy9waligxd", 6 + handle: "desertthunder.dev", 7 + displayName: "Desert Thunder", 8 + bio: "Building things on the AT Protocol. Open source enthusiast.", 9 + followerCount: 142, 10 + followingCount: 87, 11 + }, 12 + { 13 + did: "did:plc:a1b2c3d4e5f6g7h8i9j0k1l2", 14 + handle: "alice.tngl.sh", 15 + displayName: "Alice Chen", 16 + bio: "Distributed systems @ Tangled. TypeScript, Go, Rust.", 17 + followerCount: 891, 18 + followingCount: 234, 19 + }, 20 + { 21 + did: "did:plc:b2c3d4e5f6g7h8i9j0k1l2m3", 22 + handle: "bob.tngl.sh", 23 + displayName: "Bob Nakamura", 24 + bio: "Open source contributor. Loves compilers and weird edge cases.", 25 + followerCount: 307, 26 + followingCount: 412, 27 + }, 28 + { 29 + did: "did:plc:c3d4e5f6g7h8i9j0k1l2m3n4", 30 + handle: "clara.bsky.social", 31 + displayName: "Clara Osei", 32 + bio: "Frontend dev. Making the decentralized web feel fast.", 33 + followerCount: 554, 34 + followingCount: 198, 35 + }, 36 + { 37 + did: "did:plc:d4e5f6g7h8i9j0k1l2m3n4o5", 38 + handle: "dev.tangled.sh", 39 + displayName: "Tangled Dev", 40 + bio: "Official Tangled development account.", 41 + followerCount: 4210, 42 + followingCount: 12, 43 + }, 44 + { 45 + did: "did:plc:e5f6g7h8i9j0k1l2m3n4o5p6", 46 + handle: "riku.tngl.sh", 47 + displayName: "Riku Mäkinen", 48 + bio: "Systems programmer. NixOS, Git internals, coffee.", 49 + followerCount: 228, 50 + followingCount: 315, 51 + }, 52 52 ]; 53 53 54 54 export function getMockUsers(): UserSummary[] { 55 - return MOCK_USERS; 55 + return MOCK_USERS; 56 56 } 57 57 58 58 export function getMockUser(handle: string): UserSummary | undefined { 59 - return MOCK_USERS.find((u) => u.handle === handle); 59 + return MOCK_USERS.find((u) => u.handle === handle); 60 60 }
+46 -12
src/services/tangled/endpoints.ts
··· 30 30 ShTangledActorProfile, 31 31 } from "@atcute/tangled"; 32 32 import { throwOnXrpcError } from "@/services/atproto/client.js"; 33 + import { MalformedResponseError } from "@/core/errors/tangled.js"; 33 34 34 35 export async function fetchRepoTree( 35 36 client: Client, ··· 95 96 } 96 97 97 98 /** Tag list. Wire format is a raw blob — decoded text returned for normalizer. */ 98 - export async function fetchRepoTags( 99 - client: Client, 100 - params: ShTangledRepoTags.$params, 101 - ): Promise<string> { 99 + export async function fetchRepoTags(client: Client, params: ShTangledRepoTags.$params): Promise<string> { 102 100 const res = await client.get("sh.tangled.repo.tags", { params, as: "bytes" }); 103 101 if (!res.ok) throwOnXrpcError(res.status, (res.data as { error: string }).error); 104 102 return new TextDecoder().decode(res.data as Uint8Array); 105 103 } 106 104 107 105 /** Diff for a ref. Wire format is a raw blob — patch text. */ 108 - export async function fetchRepoDiff( 109 - client: Client, 110 - params: ShTangledRepoDiff.$params, 111 - ): Promise<string> { 106 + export async function fetchRepoDiff(client: Client, params: ShTangledRepoDiff.$params): Promise<string> { 112 107 const res = await client.get("sh.tangled.repo.diff", { params, as: "bytes" }); 113 108 if (!res.ok) throwOnXrpcError(res.status, (res.data as { error: string }).error); 114 109 return new TextDecoder().decode(res.data as Uint8Array); 115 110 } 116 111 117 112 /** Comparison between two revisions. Wire format is a raw blob — patch text. */ 118 - export async function fetchRepoCompare( 119 - client: Client, 120 - params: ShTangledRepoCompare.$params, 121 - ): Promise<string> { 113 + export async function fetchRepoCompare(client: Client, params: ShTangledRepoCompare.$params): Promise<string> { 122 114 const res = await client.get("sh.tangled.repo.compare", { params, as: "bytes" }); 123 115 if (!res.ok) throwOnXrpcError(res.status, (res.data as { error: string }).error); 124 116 return new TextDecoder().decode(res.data as Uint8Array); ··· 163 155 repoName: string, 164 156 ): Promise<GetRecordResponse<ShTangledRepo.Main>> { 165 157 return getRecord<ShTangledRepo.Main>(pds, did, "sh.tangled.repo", repoName); 158 + } 159 + 160 + /** 161 + * Resolve an AT Protocol handle to a DID via bsky.social. 162 + * Returns the DID string (e.g. "did:plc:xxx"). 163 + */ 164 + export async function resolveHandle(handle: string): Promise<string> { 165 + const url = new URL("https://bsky.social/xrpc/com.atproto.identity.resolveHandle"); 166 + url.searchParams.set("handle", handle); 167 + const res = await fetch(url.toString()); 168 + if (!res.ok) { 169 + const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; 170 + throwOnXrpcError(res.status, body.error ?? "Unknown", body.message); 171 + } 172 + const data = (await res.json()) as { did: string }; 173 + return data.did; 174 + } 175 + 176 + type DidDocument = { service?: Array<{ id: string; type: string; serviceEndpoint: string }> }; 177 + 178 + /** 179 + * Fetch the DID document for a DID and extract the PDS service endpoint hostname. 180 + * Supports did:plc (via plc.directory) and did:web. 181 + */ 182 + export async function resolvePds(did: string): Promise<string> { 183 + let docUrl: string; 184 + if (did.startsWith("did:plc:")) { 185 + docUrl = `https://plc.directory/${did}`; 186 + } else if (did.startsWith("did:web:")) { 187 + const host = did.slice("did:web:".length); 188 + docUrl = `https://${host}/.well-known/did.json`; 189 + } else { 190 + throw new MalformedResponseError("resolveHandle", `Unsupported DID method: ${did}`); 191 + } 192 + const res = await fetch(docUrl); 193 + if (!res.ok) throwOnXrpcError(res.status, "ResolveFailed", `Could not fetch DID document: ${did}`); 194 + const doc = (await res.json()) as DidDocument; 195 + const svc = doc.service?.find((s) => s.id === "#atproto_pds"); 196 + if (!svc?.serviceEndpoint) { 197 + throw new MalformedResponseError("resolvePds", `No PDS endpoint in DID document: ${did}`); 198 + } 199 + return new URL(svc.serviceEndpoint).hostname; 166 200 } 167 201 168 202 /**
+67 -16
src/services/tangled/queries.ts
··· 3 3 * These are the only entry points Vue components should use — no direct 4 4 * imports of @atcute/* or service/endpoint functions in components. 5 5 * 6 - * Cache strategy (from spec): 6 + * Cache strategy: 7 7 * Repo metadata stale: 5m gc: 30m 8 8 * File tree stale: 2m gc: 10m 9 9 * File content stale: 5m gc: 30m ··· 30 30 fetchActorProfile, 31 31 fetchRepoRecord, 32 32 listRepoRecords, 33 + resolveHandle, 34 + resolvePds, 33 35 } from "./endpoints.js"; 34 36 import { 35 37 normalizeTree, ··· 43 45 normalizeRepoRecord, 44 46 } from "./normalizers.js"; 45 47 48 + export type { CommitEntry, BranchEntry, BlobContent, DefaultBranchInfo } from "./normalizers.js"; 49 + 46 50 const MIN = 60_000; 47 51 52 + /** Resolved identity: DID + PDS hostname for an AT Protocol handle. */ 53 + export type Identity = { did: string; pds: string }; 54 + 55 + /** 56 + * Resolve an AT Protocol handle to its DID and PDS hostname. 57 + * Result is cached for 10 minutes (handles rarely change). 58 + */ 59 + export function useIdentity(handle: MaybeRef<string>) { 60 + return useQuery({ 61 + queryKey: computed(() => ["identity", toValue(handle)]), 62 + queryFn: async (): Promise<Identity> => { 63 + const did = await resolveHandle(toValue(handle)); 64 + const pds = await resolvePds(did); 65 + return { did, pds }; 66 + }, 67 + staleTime: 10 * MIN, 68 + gcTime: 60 * MIN, 69 + }); 70 + } 71 + 48 72 /** File tree for a path within a repo. */ 49 73 export function useRepoTree( 50 74 knotHost: MaybeRef<string>, 51 75 repo: MaybeRef<string>, 52 76 ref: MaybeRef<string>, 53 77 path: MaybeRef<string | undefined> = undefined, 78 + options: { enabled?: MaybeRef<boolean> } = {}, 54 79 ) { 55 80 return useQuery({ 56 81 queryKey: computed(() => ["tree", toValue(knotHost), toValue(repo), toValue(ref), toValue(path)]), ··· 60 85 ref: toValue(ref), 61 86 path: toValue(path), 62 87 }).then((out) => normalizeTree(out, toValue(path) ?? "")), 88 + enabled: options.enabled, 63 89 staleTime: 2 * MIN, 64 90 gcTime: 10 * MIN, 65 91 }); ··· 71 97 repo: MaybeRef<string>, 72 98 ref: MaybeRef<string>, 73 99 path: MaybeRef<string>, 74 - options: { readme?: boolean } = {}, 100 + options: { readme?: boolean; enabled?: MaybeRef<boolean> } = {}, 75 101 ) { 76 102 return useQuery({ 77 103 queryKey: computed(() => ["blob", toValue(knotHost), toValue(repo), toValue(ref), toValue(path)]), ··· 81 107 ref: toValue(ref), 82 108 path: toValue(path), 83 109 }).then(normalizeBlob), 84 - staleTime: options.readme ? 5 * MIN : 5 * MIN, 85 - gcTime: options.readme ? 30 * MIN : 30 * MIN, 110 + enabled: options.enabled, 111 + staleTime: 5 * MIN, 112 + gcTime: 30 * MIN, 86 113 }); 87 114 } 88 115 89 116 /** Default branch name + latest commit for a repo. */ 90 - export function useDefaultBranch(knotHost: MaybeRef<string>, repo: MaybeRef<string>) { 117 + export function useDefaultBranch( 118 + knotHost: MaybeRef<string>, 119 + repo: MaybeRef<string>, 120 + options: { enabled?: MaybeRef<boolean> } = {}, 121 + ) { 91 122 return useQuery({ 92 123 queryKey: computed(() => ["defaultBranch", toValue(knotHost), toValue(repo)]), 93 124 queryFn: () => 94 125 fetchDefaultBranch(getKnotClient(toValue(knotHost)), { repo: toValue(repo) }).then(normalizeDefaultBranch), 126 + enabled: options.enabled, 95 127 staleTime: 5 * MIN, 96 128 gcTime: 30 * MIN, 97 129 }); ··· 102 134 knotHost: MaybeRef<string>, 103 135 repo: MaybeRef<string>, 104 136 ref?: MaybeRef<string | undefined>, 137 + options: { enabled?: MaybeRef<boolean> } = {}, 105 138 ) { 106 139 return useQuery({ 107 140 queryKey: computed(() => ["languages", toValue(knotHost), toValue(repo), toValue(ref)]), ··· 109 142 fetchLanguages(getKnotClient(toValue(knotHost)), { repo: toValue(repo), ref: toValue(ref) }).then( 110 143 normalizeLanguages, 111 144 ), 145 + enabled: options.enabled, 112 146 staleTime: 5 * MIN, 113 147 gcTime: 30 * MIN, 114 148 }); ··· 119 153 knotHost: MaybeRef<string>, 120 154 repo: MaybeRef<string>, 121 155 ref: MaybeRef<string>, 122 - options: { path?: MaybeRef<string | undefined>; limit?: number; cursor?: MaybeRef<string | undefined> } = {}, 156 + options: { 157 + path?: MaybeRef<string | undefined>; 158 + limit?: number; 159 + cursor?: MaybeRef<string | undefined>; 160 + enabled?: MaybeRef<boolean>; 161 + } = {}, 123 162 ) { 124 163 return useQuery({ 125 164 queryKey: computed(() => [ ··· 138 177 limit: options.limit, 139 178 cursor: toValue(options.cursor), 140 179 }).then(normalizeLogText), 180 + enabled: options.enabled, 141 181 staleTime: 2 * MIN, 142 182 gcTime: 10 * MIN, 143 183 }); ··· 148 188 knotHost: MaybeRef<string>, 149 189 repo: MaybeRef<string>, 150 190 defaultBranch?: MaybeRef<string | undefined>, 191 + options: { enabled?: MaybeRef<boolean> } = {}, 151 192 ) { 152 193 return useQuery({ 153 194 queryKey: computed(() => ["branches", toValue(knotHost), toValue(repo)]), ··· 155 196 fetchRepoBranches(getKnotClient(toValue(knotHost)), { repo: toValue(repo) }).then((raw) => 156 197 normalizeBranchesText(raw, toValue(defaultBranch)), 157 198 ), 199 + enabled: options.enabled, 158 200 staleTime: 2 * MIN, 159 201 gcTime: 10 * MIN, 160 202 }); ··· 169 211 did: MaybeRef<string>, 170 212 repoName: MaybeRef<string>, 171 213 handle: MaybeRef<string>, 214 + options: { enabled?: MaybeRef<boolean> } = {}, 172 215 ) { 173 216 return useQuery({ 174 217 queryKey: computed(() => ["repoRecord", toValue(pds), toValue(did), toValue(repoName)]), ··· 179 222 })); 180 223 return normalizeRepoRecord(record, toValue(did), toValue(handle), uri); 181 224 }, 225 + enabled: options.enabled, 182 226 staleTime: 5 * MIN, 183 227 gcTime: 30 * MIN, 184 228 }); 185 229 } 186 230 187 231 /** List all repos for a user from their PDS. */ 188 - export function useUserRepos(pds: MaybeRef<string>, did: MaybeRef<string>, handle: MaybeRef<string>) { 232 + export function useUserRepos( 233 + pds: MaybeRef<string>, 234 + did: MaybeRef<string>, 235 + handle: MaybeRef<string>, 236 + options: { enabled?: MaybeRef<boolean> } = {}, 237 + ) { 189 238 return useQuery({ 190 239 queryKey: computed(() => ["userRepos", toValue(pds), toValue(did)]), 191 240 queryFn: async () => { 192 241 const { records } = await listRepoRecords(toValue(pds), toValue(did)); 193 242 return records.map((r) => normalizeRepoRecord(r.value, toValue(did), toValue(handle), r.uri)); 194 243 }, 244 + enabled: options.enabled, 195 245 staleTime: 5 * MIN, 196 246 gcTime: 30 * MIN, 197 247 }); ··· 203 253 did: MaybeRef<string>, 204 254 handle: MaybeRef<string>, 205 255 displayName?: MaybeRef<string | undefined>, 256 + options: { enabled?: MaybeRef<boolean> } = {}, 206 257 ) { 207 258 return useQuery({ 208 259 queryKey: computed(() => ["actorProfile", toValue(pds), toValue(did)]), ··· 210 261 const { value } = await fetchActorProfile(toValue(pds), toValue(did)); 211 262 return normalizeActorProfile(value, toValue(did), toValue(handle), toValue(displayName)); 212 263 }, 264 + enabled: options.enabled, 213 265 staleTime: 10 * MIN, 214 266 gcTime: 60 * MIN, 215 267 }); ··· 219 271 export function useRepoTags( 220 272 knotHost: MaybeRef<string>, 221 273 repo: MaybeRef<string>, 274 + options: { enabled?: MaybeRef<boolean> } = {}, 222 275 ) { 223 276 return useQuery({ 224 277 queryKey: computed(() => ["tags", toValue(knotHost), toValue(repo)]), 225 278 queryFn: () => 226 279 fetchRepoTags(getKnotClient(toValue(knotHost)), { repo: toValue(repo) }).then((raw) => 227 - raw 228 - .trim() 229 - .split("\n") 230 - .filter(Boolean), 280 + raw.trim().split("\n").filter(Boolean), 231 281 ), 282 + enabled: options.enabled, 232 283 staleTime: 2 * MIN, 233 284 gcTime: 10 * MIN, 234 285 }); ··· 239 290 knotHost: MaybeRef<string>, 240 291 repo: MaybeRef<string>, 241 292 ref: MaybeRef<string>, 293 + options: { enabled?: MaybeRef<boolean> } = {}, 242 294 ) { 243 295 return useQuery({ 244 296 queryKey: computed(() => ["diff", toValue(knotHost), toValue(repo), toValue(ref)]), 245 - queryFn: () => 246 - fetchRepoDiff(getKnotClient(toValue(knotHost)), { 247 - repo: toValue(repo), 248 - ref: toValue(ref), 249 - }), 297 + queryFn: () => fetchRepoDiff(getKnotClient(toValue(knotHost)), { repo: toValue(repo), ref: toValue(ref) }), 298 + enabled: options.enabled, 250 299 staleTime: 5 * MIN, 251 300 gcTime: 30 * MIN, 252 301 }); ··· 258 307 repo: MaybeRef<string>, 259 308 rev1: MaybeRef<string>, 260 309 rev2: MaybeRef<string>, 310 + options: { enabled?: MaybeRef<boolean> } = {}, 261 311 ) { 262 312 return useQuery({ 263 313 queryKey: computed(() => ["compare", toValue(knotHost), toValue(repo), toValue(rev1), toValue(rev2)]), ··· 267 317 rev1: toValue(rev1), 268 318 rev2: toValue(rev2), 269 319 }), 320 + enabled: options.enabled, 270 321 staleTime: 5 * MIN, 271 322 gcTime: 30 * MIN, 272 323 });