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: repo file viewer and PRs

+515 -333
-27
.eslintignore
··· 1 - .DS_Store 2 - node_modules 3 - /coverage 4 - /dist 5 - /ios 6 - /android 7 - 8 - 9 - # local env files 10 - .env.local 11 - .env.*.local 12 - 13 - # Log files 14 - npm-debug.log* 15 - yarn-debug.log* 16 - yarn-error.log* 17 - pnpm-debug.log* 18 - 19 - # Editor directories and files 20 - .idea 21 - .vscode 22 - *.suo 23 - *.ntvs* 24 - *.njsproj 25 - *.sln 26 - *.sw? 27 -
-21
.eslintrc.cjs
··· 1 - module.exports = { 2 - root: true, 3 - env: { 4 - node: true, 5 - }, 6 - extends: [ 7 - "plugin:vue/vue3-essential", 8 - "eslint:recommended", 9 - "@vue/typescript/recommended", 10 - ], 11 - parserOptions: { 12 - ecmaVersion: 2020, 13 - }, 14 - rules: { 15 - "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", 16 - "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", 17 - "vue/no-deprecated-slot-attribute": "off", 18 - "@typescript-eslint/no-explicit-any": "off", 19 - "vue/max-template-depth": ["warn", { maxDepth: 4 }], 20 - }, 21 - };
+1 -6
.prettierrc
··· 1 - { 2 - "tabWidth": 2, 3 - "printWidth": 120, 4 - "objectWrap": "collapse", 5 - "bracketSameLine": true 6 - } 1 + { "tabWidth": 2, "printWidth": 120, "objectWrap": "collapse", "bracketSameLine": true, "vueIndentScriptAndStyle": true }
+15
README.md
··· 1 1 # Twisted 2 2 3 3 A mobile client for [Tangled](https://tangled.org). 4 + 5 + ## Development 6 + 7 + Run the mobile apps with Capacitor: 8 + 9 + ```bash 10 + pnpm cap run ios 11 + pnpm cap run android 12 + ``` 13 + 14 + Or to test the web version: 15 + 16 + ```bash 17 + pnpm dev 18 + ```
+49
eslint.config.js
··· 1 + import js from "@eslint/js"; 2 + import pluginVue from "eslint-plugin-vue"; 3 + import globals from "globals"; 4 + import tseslint from "typescript-eslint"; 5 + 6 + const isProduction = process.env.NODE_ENV === "production"; 7 + 8 + export default tseslint.config( 9 + { 10 + ignores: [ 11 + "**/.DS_Store", 12 + "**/node_modules/**", 13 + "coverage/**", 14 + "dist/**", 15 + "ios/**", 16 + "android/**", 17 + ".env.local", 18 + ".env.*.local", 19 + "npm-debug.log*", 20 + "yarn-debug.log*", 21 + "yarn-error.log*", 22 + "pnpm-debug.log*", 23 + ".idea/**", 24 + ".vscode/**", 25 + "*.suo", 26 + "*.ntvs*", 27 + "*.njsproj", 28 + "*.sln", 29 + "*.sw?", 30 + ], 31 + }, 32 + { 33 + extends: [js.configs.recommended, ...tseslint.configs.recommended, ...pluginVue.configs["flat/essential"]], 34 + files: ["**/*.{js,mjs,cjs,ts,mts,cts,vue}"], 35 + languageOptions: { 36 + ecmaVersion: "latest", 37 + sourceType: "module", 38 + globals: { ...globals.browser, ...globals.node }, 39 + parserOptions: { parser: tseslint.parser }, 40 + }, 41 + rules: { 42 + "no-console": isProduction ? "warn" : "off", 43 + "no-debugger": isProduction ? "warn" : "off", 44 + "@typescript-eslint/no-explicit-any": "off", 45 + "vue/no-deprecated-slot-attribute": "off", 46 + }, 47 + }, 48 + { files: ["**/*.cjs"], languageOptions: { sourceType: "commonjs" } }, 49 + );
+1
package.json
··· 36 36 "devDependencies": { 37 37 "@capacitor/cli": "8.2.0", 38 38 "@eslint/js": "10.0.1", 39 + "@types/node": "25.5.0", 39 40 "@vitejs/plugin-legacy": "^5.0.0", 40 41 "@vitejs/plugin-vue": "^4.0.0", 41 42 "@vue/test-utils": "^2.3.0",
+3
pnpm-lock.yaml
··· 72 72 '@eslint/js': 73 73 specifier: 10.0.1 74 74 version: 10.0.1(eslint@10.1.0) 75 + '@types/node': 76 + specifier: 25.5.0 77 + version: 25.5.0 75 78 '@vitejs/plugin-legacy': 76 79 specifier: ^5.0.0 77 80 version: 5.4.3(terser@5.46.1)(vite@5.4.21(@types/node@25.5.0)(terser@5.46.1))
+1 -2
src/components/common/UserCard.vue
··· 3 3 <ion-card-content class="card-body"> 4 4 <div class="user-row"> 5 5 <ion-avatar class="avatar"> 6 - <img v-if="user.avatar" :src="user.avatar" :alt="user.displayName ?? user.handle" /> 7 - <div v-else class="avatar-fallback" :style="{ background: avatarColor(user.handle) }"> 6 + <div class="avatar-fallback" :style="{ background: avatarColor(user.handle) }"> 8 7 {{ initials(user.handle) }} 9 8 </div> 10 9 </ion-avatar>
+11 -8
src/components/repo/FileTreeItem.vue
··· 1 1 <template> 2 2 <ion-item class="file-item" :lines="lines" button @click="emit('click')"> 3 - <ion-icon 4 - slot="start" 5 - :icon="file.type === 'dir' ? folderOutline : documentOutline" 6 - class="file-icon" 7 - :class="file.type" /> 3 + <ion-icon slot="start" :icon="fileIcon" class="file-icon" :class="file.type" /> 8 4 <ion-label class="file-label"> 9 5 <span class="file-name">{{ file.name }}</span> 10 6 <span v-if="file.lastCommitMessage" class="commit-msg">{{ file.lastCommitMessage }}</span> ··· 16 12 </template> 17 13 18 14 <script setup lang="ts"> 15 + import { computed } from "vue"; 19 16 import { IonItem, IonLabel, IonIcon } from "@ionic/vue"; 20 - import { folderOutline, documentOutline, chevronForwardOutline } from "ionicons/icons"; 21 - import type { RepoFile } from "@/domain/models/repo"; 17 + import { folderOpenOutline, documentTextOutline, gitBranchOutline, chevronForwardOutline } from "ionicons/icons"; 18 + import type { RepoFile } from "@/domain/models/repo.js"; 22 19 23 - defineProps<{ file: RepoFile; lines?: "full" | "inset" | "none" }>(); 20 + const props = defineProps<{ file: RepoFile; lines?: "full" | "inset" | "none" }>(); 24 21 25 22 const emit = defineEmits<{ click: [] }>(); 23 + 24 + const fileIcon = computed(() => { 25 + if (props.file.type === "dir") return folderOpenOutline; 26 + if (props.file.type === "submodule") return gitBranchOutline; 27 + return documentTextOutline; 28 + }); 26 29 </script> 27 30 28 31 <style scoped>
+1
src/domain/models/repo.ts
··· 2 2 3 3 export type RepoSummary = { 4 4 atUri: string; 5 + rkey: string; 5 6 ownerDid: string; 6 7 ownerHandle: string; 7 8 name: string;
+2 -2
src/features/home/HomePage.vue
··· 14 14 </ion-header> 15 15 16 16 <section class="hero"> 17 - <p class="eyebrow">Known-handle browsing</p> 17 + <p class="eyebrow">Profile Browser</p> 18 18 <h1 class="hero-title">Jump straight to a Tangled profile or browse that handle's repos.</h1> 19 19 <p class="hero-copy"> 20 20 Enter an AT Protocol handle, then open the profile directly or resolve the user's Personal Data Server and ··· 96 96 <EmptyState 97 97 :icon="compassOutline" 98 98 title="Browse by handle" 99 - message="Use Home as the temporary public entry point while search and activity are still in progress." /> 99 + message="Browse Tangled repositories by entering a handle above." /> 100 100 </section> 101 101 </ion-content> 102 102 </ion-page>
+3 -12
src/features/profile/UserProfilePage.vue
··· 24 24 <template v-else> 25 25 <div class="profile-header"> 26 26 <ion-avatar class="avatar"> 27 - <img 28 - v-if="profile" 29 - :src="`https://avatar.tangled.sh/${identity.data.value?.did}`" 30 - :alt="handle" 31 - @error="avatarError = true" /> 32 - <div v-if="!profile || avatarError" class="avatar-fallback" :style="{ background: avatarColor(handle) }"> 27 + <div class="avatar-fallback" :style="{ background: avatarColor(handle) }"> 33 28 {{ initials(handle) }} 34 29 </div> 35 30 </ion-avatar> ··· 187 182 useUserPullRequests, 188 183 useUserFollowing, 189 184 } from "@/services/tangled/queries.js"; 190 - import { parseAtUri } from "@/services/tangled/uris.js"; 191 185 import type { IssueSummary } from "@/domain/models/issue.js"; 192 186 import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 193 187 import type { RepoSummary } from "@/domain/models/repo.js"; ··· 196 190 const router = useRouter(); 197 191 const handle = computed(() => String(route.params.handle ?? "")); 198 192 const section = ref<"repos" | "strings" | "issues" | "prs" | "following">("repos"); 199 - 200 - const avatarError = ref(false); 201 193 202 194 const identity = useIdentity(handle); 203 195 const did = computed(() => identity.data.value?.did ?? ""); ··· 242 234 }); 243 235 244 236 watch(handle, () => { 245 - avatarError.value = false; 246 237 section.value = "repos"; 247 238 }); 248 239 ··· 255 246 } 256 247 257 248 function navigateToIssue(issue: IssueSummary) { 258 - const repoName = parseAtUri(issue.repoAtUri)?.rkey; 249 + const repoName = repos.value.find((repo) => repo.atUri === issue.repoAtUri)?.name; 259 250 if (!repoName) return; 260 251 router.push(`${tabPrefix.value}/repo/${handle.value}/${repoName}/issues/${issue.rkey}`); 261 252 } 262 253 263 254 function navigateToPullRequest(pullRequest: PullRequestSummary) { 264 - const repoName = parseAtUri(pullRequest.targetRepoAtUri)?.rkey; 255 + const repoName = repos.value.find((repo) => repo.atUri === pullRequest.targetRepoAtUri)?.name; 265 256 if (!repoName) return; 266 257 router.push(`${tabPrefix.value}/repo/${handle.value}/${repoName}/pulls/${pullRequest.rkey}`); 267 258 }
+5 -5
src/features/repo/IssueDetailPage.vue
··· 95 95 import { useIdentity, useRepoRecord, useIssueDetail, useIssueComments } from "@/services/tangled/queries.js"; 96 96 97 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; 98 + const owner = computed(() => String(route.params.owner ?? "")); 99 + const repoName = computed(() => String(route.params.repo ?? "")); 100 + const issueId = computed(() => String(route.params.issueId ?? "")); 101 101 102 - const identity = useIdentity(owner); 102 + const identity = useIdentity(owner, { enabled: computed(() => !!owner.value) }); 103 103 const did = computed(() => identity.data.value?.did ?? ""); 104 104 const pds = computed(() => identity.data.value?.pds ?? ""); 105 105 const hasIdentity = computed(() => !!identity.data.value); ··· 140 140 return "/tabs/home"; 141 141 }); 142 142 143 - const backHref = computed(() => `${tabPrefix.value}/repo/${owner}/${repoName}?tab=issues`); 143 + const backHref = computed(() => `${tabPrefix.value}/repo/${owner.value}/${repoName.value}?tab=issues`); 144 144 145 145 function relativeTime(iso: string): string { 146 146 const timestamp = Date.parse(iso);
+5 -5
src/features/repo/PullRequestDetailPage.vue
··· 109 109 } from "@/services/tangled/queries.js"; 110 110 111 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; 112 + const owner = computed(() => String(route.params.owner ?? "")); 113 + const repoName = computed(() => String(route.params.repo ?? "")); 114 + const pullId = computed(() => String(route.params.pullId ?? "")); 115 115 116 - const identity = useIdentity(owner); 116 + const identity = useIdentity(owner, { enabled: computed(() => !!owner.value) }); 117 117 const did = computed(() => identity.data.value?.did ?? ""); 118 118 const pds = computed(() => identity.data.value?.pds ?? ""); 119 119 const hasIdentity = computed(() => !!identity.data.value); ··· 153 153 return "/tabs/home"; 154 154 }); 155 155 156 - const backHref = computed(() => `${tabPrefix.value}/repo/${owner}/${repoName}?tab=prs`); 156 + const backHref = computed(() => `${tabPrefix.value}/repo/${owner.value}/${repoName.value}?tab=prs`); 157 157 158 158 function relativeTime(iso: string): string { 159 159 const timestamp = Date.parse(iso);
+7 -11
src/features/repo/RepoDetailPage.vue
··· 6 6 <ion-back-button default-href="/tabs/home" /> 7 7 </ion-buttons> 8 8 <ion-title class="repo-title"> 9 - <span class="owner">{{ owner }}/</span>{{ repoName }} 9 + <span class="owner">{{ owner }}/</span>{{ repo?.name ?? repoName }} 10 10 </ion-title> 11 11 </ion-toolbar> 12 12 <ion-toolbar> ··· 41 41 <RepoOverview v-if="segment === 'overview'" :repo="repo" :commits="commits" /> 42 42 <RepoFiles 43 43 v-else-if="segment === 'files'" 44 - :files="files" 45 44 :knot-host="knotHost" 46 45 :knot-repo="knotRepo" 47 46 :branch="defaultBranch" /> ··· 85 84 useIdentity, 86 85 useRepoRecord, 87 86 useDefaultBranch, 88 - useRepoTree, 89 87 useRepoBlob, 90 88 useRepoLanguages, 91 89 useRepoLog, ··· 96 94 97 95 const route = useRoute(); 98 96 const router = useRouter(); 99 - const owner = route.params.owner as string; 100 - const repoName = route.params.repo as string; 97 + const owner = computed(() => String(route.params.owner ?? "")); 98 + const repoName = computed(() => String(route.params.repo ?? "")); 101 99 102 100 type Segment = "overview" | "files" | "issues" | "prs"; 103 101 ··· 125 123 router.replace({ path: route.path, query: nextQuery }); 126 124 }); 127 125 128 - const identity = useIdentity(owner); 126 + const identity = useIdentity(owner, { enabled: computed(() => !!owner.value) }); 129 127 const did = computed(() => identity.data.value?.did ?? ""); 130 128 const pds = computed(() => identity.data.value?.pds ?? ""); 131 129 const hasIdentity = computed(() => !!identity.data.value); 132 130 133 131 const recordQuery = useRepoRecord(pds, did, repoName, owner, { enabled: hasIdentity }); 134 132 const knotHost = computed(() => recordQuery.data.value?.knot ?? ""); 135 - const knotRepo = computed(() => (did.value ? `${did.value}/${repoName}` : "")); 133 + const knotRepo = computed(() => (did.value && repoName.value ? `${did.value}/${repoName.value}` : "")); 136 134 const hasRecord = computed(() => !!recordQuery.data.value?.knot && !!did.value); 137 135 138 136 const branchQuery = useDefaultBranch(knotHost, knotRepo, { enabled: hasRecord }); 139 137 const defaultBranch = computed(() => branchQuery.data.value?.name ?? ""); 140 138 const hasBranch = computed(() => !!branchQuery.data.value?.name); 141 139 142 - const treeQuery = useRepoTree(knotHost, knotRepo, defaultBranch, undefined, { enabled: hasBranch }); 143 140 const languagesQuery = useRepoLanguages(knotHost, knotRepo, undefined, { enabled: hasBranch }); 144 141 const readmeQuery = useRepoBlob(knotHost, knotRepo, defaultBranch, "README.md", { readme: true, enabled: hasBranch }); 145 142 const logQuery = useRepoLog(knotHost, knotRepo, defaultBranch, { limit: 20, enabled: hasBranch }); ··· 161 158 const issuesQuery = useRepoIssues(pds, did, owner, repoAtUri, { enabled: hasAtUri }); 162 159 const prsQuery = useRepoPRs(pds, did, owner, repoAtUri, { enabled: hasAtUri }); 163 160 164 - const files = computed(() => treeQuery.data.value ?? []); 165 161 const commits = computed(() => logQuery.data.value ?? []); 166 162 const issues = computed(() => issuesQuery.data.value ?? []); 167 163 const prs = computed(() => prsQuery.data.value ?? []); ··· 180 176 }); 181 177 182 178 function openIssue(issue: { rkey: string }) { 183 - router.push(`${tabPrefix.value}/repo/${owner}/${repoName}/issues/${issue.rkey}?tab=issues`); 179 + router.push(`${tabPrefix.value}/repo/${owner.value}/${repoName.value}/issues/${issue.rkey}?tab=issues`); 184 180 } 185 181 186 182 function openPullRequest(pr: { rkey: string }) { 187 - router.push(`${tabPrefix.value}/repo/${owner}/${repoName}/pulls/${pr.rkey}?tab=prs`); 183 + router.push(`${tabPrefix.value}/repo/${owner.value}/${repoName.value}/pulls/${pr.rkey}?tab=prs`); 188 184 } 189 185 </script> 190 186
+54 -16
src/features/repo/RepoFiles.vue
··· 1 1 <template> 2 2 <div class="files-view"> 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"> 3 + <div v-if="selectedFile || currentPath" class="viewer-header"> 4 + <ion-button fill="clear" size="small" class="back-btn" @click="goBack"> 6 5 <ion-icon slot="start" :icon="arrowBackOutline" /> 7 - Files 6 + {{ selectedFile ? "Files" : "Up" }} 8 7 </ion-button> 9 - <span class="file-path mono">{{ selectedFile.path }}</span> 8 + <span class="file-path mono">{{ selectedFile ? selectedFile.path : currentPath }}</span> 10 9 </div> 11 10 12 - <!-- File viewer --> 13 11 <template v-if="selectedFile"> 14 12 <template v-if="blobQuery.isPending.value"> 15 13 <SkeletonLoader v-for="n in 6" :key="n" variant="list-item" /> ··· 35 33 </template> 36 34 </template> 37 35 38 - <!-- File tree --> 39 36 <template v-else> 40 - <ion-list lines="inset" class="file-list"> 41 - <FileTreeItem v-for="file in sortedFiles" :key="file.name" :file="file" @click="handleFileClick(file)" /> 37 + <template v-if="treeQuery.isPending.value"> 38 + <SkeletonLoader v-for="n in 6" :key="n" variant="list-item" /> 39 + </template> 40 + <EmptyState 41 + v-else-if="treeQuery.isError.value" 42 + :icon="alertCircleOutline" 43 + title="Could not load files" 44 + :message="treeQuery.error.value instanceof Error ? treeQuery.error.value.message : 'Unknown error'" /> 45 + <ion-list v-else lines="inset" class="file-list"> 46 + <FileTreeItem v-for="file in sortedFiles" :key="file.path" :file="file" @click="handleFileClick(file)" /> 42 47 </ion-list> 43 48 <EmptyState 44 - v-if="!files.length" 49 + v-if="!sortedFiles.length && !treeQuery.isPending.value && !treeQuery.isError.value" 45 50 :icon="folderOpenOutline" 46 51 title="No files" 47 - message="This repository appears to be empty." /> 52 + :message="currentPath ? 'This directory is empty.' : 'This repository appears to be empty.'" /> 48 53 </template> 49 54 </div> 50 55 </template> ··· 56 61 import FileTreeItem from "@/components/repo/FileTreeItem.vue"; 57 62 import EmptyState from "@/components/common/EmptyState.vue"; 58 63 import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 59 - import { useRepoBlob } from "@/services/tangled/queries.js"; 64 + import { useRepoBlob, useRepoTree } from "@/services/tangled/queries.js"; 60 65 import type { RepoFile } from "@/domain/models/repo.js"; 61 66 62 - const props = defineProps<{ files: RepoFile[]; knotHost: string; knotRepo: string; branch: string }>(); 67 + const props = defineProps<{ knotHost: string; knotRepo: string; branch: string }>(); 63 68 64 69 const selectedFile = ref<RepoFile | null>(null); 70 + const currentPath = ref(""); 71 + 72 + const treeQuery = useRepoTree( 73 + computed(() => props.knotHost), 74 + computed(() => props.knotRepo), 75 + computed(() => props.branch), 76 + currentPath, 77 + { enabled: computed(() => !!props.knotHost && !!props.knotRepo && !!props.branch) }, 78 + ); 65 79 66 80 const sortedFiles = computed(() => { 67 - return [...props.files].sort((a, b) => { 81 + const files = treeQuery.data.value ?? []; 82 + return [...files].sort((a, b) => { 68 83 if (a.type === b.type) return a.name.localeCompare(b.name); 69 - return a.type === "dir" ? -1 : 1; 84 + if (a.type === "dir") return -1; 85 + if (b.type === "dir") return 1; 86 + if (a.type === "submodule") return -1; 87 + if (b.type === "submodule") return 1; 88 + return 0; 70 89 }); 71 90 }); 72 91 ··· 82 101 ); 83 102 84 103 function handleFileClick(file: RepoFile) { 85 - if (file.type === "dir") return; // TODO: navigate into directories 104 + if (file.type === "dir") { 105 + currentPath.value = file.path; 106 + selectedFile.value = null; 107 + return; 108 + } 109 + 110 + if (file.type === "submodule") return; 111 + 86 112 selectedFile.value = file; 113 + } 114 + 115 + function goBack() { 116 + if (selectedFile.value) { 117 + selectedFile.value = null; 118 + return; 119 + } 120 + 121 + if (!currentPath.value) return; 122 + const segments = currentPath.value.split("/").filter(Boolean); 123 + segments.pop(); 124 + currentPath.value = segments.join("/"); 87 125 } 88 126 89 127 function formatSize(bytes: number): string {
+139 -140
src/features/repo/RepoPRs.vue
··· 5 5 </div> 6 6 7 7 <template v-else> 8 - <!-- Filter --> 9 8 <div class="filters-row"> 10 9 <ion-chip 11 10 v-for="f in FILTERS" ··· 56 55 </template> 57 56 58 57 <script setup lang="ts"> 59 - import { ref, computed } from "vue"; 60 - import { IonList, IonItem, IonLabel, IonBadge, IonIcon, IonChip, IonSpinner } from "@ionic/vue"; 61 - import { gitMergeOutline } from "ionicons/icons"; 62 - import EmptyState from "@/components/common/EmptyState.vue"; 63 - import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 58 + import { ref, computed } from "vue"; 59 + import { IonList, IonItem, IonLabel, IonBadge, IonIcon, IonChip, IonSpinner } from "@ionic/vue"; 60 + import { gitMergeOutline } from "ionicons/icons"; 61 + import EmptyState from "@/components/common/EmptyState.vue"; 62 + import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 64 63 65 - const props = defineProps<{ prs: PullRequestSummary[]; isLoading?: boolean }>(); 66 - const emit = defineEmits<{ select: [pr: PullRequestSummary] }>(); 64 + const props = defineProps<{ prs: PullRequestSummary[]; isLoading?: boolean }>(); 65 + const emit = defineEmits<{ select: [pr: PullRequestSummary] }>(); 67 66 68 - const filter = ref<"all" | "open" | "merged" | "closed">("open"); 67 + const filter = ref<"all" | "open" | "merged" | "closed">("open"); 69 68 70 - const FILTERS = [ 71 - { value: "open", label: "Open" }, 72 - { value: "merged", label: "Merged" }, 73 - { value: "closed", label: "Closed" }, 74 - { value: "all", label: "All" }, 75 - ] as const; 69 + const FILTERS = [ 70 + { value: "open", label: "Open" }, 71 + { value: "merged", label: "Merged" }, 72 + { value: "closed", label: "Closed" }, 73 + { value: "all", label: "All" }, 74 + ] as const; 76 75 77 - const filtered = computed(() => { 78 - if (filter.value === "all") return props.prs; 79 - return props.prs.filter((pr) => pr.status === filter.value); 80 - }); 76 + const filtered = computed(() => { 77 + if (filter.value === "all") return props.prs; 78 + return props.prs.filter((pr) => pr.status === filter.value); 79 + }); 81 80 82 - function relativeTime(iso: string): string { 83 - const diff = Date.now() - new Date(iso).getTime(); 84 - const m = Math.floor(diff / 60000); 85 - const h = Math.floor(m / 60); 86 - const d = Math.floor(h / 24); 87 - if (d > 0) return `${d}d ago`; 88 - if (h > 0) return `${h}h ago`; 89 - if (m > 0) return `${m}m ago`; 90 - return "just now"; 91 - } 81 + function relativeTime(iso: string): string { 82 + const diff = Date.now() - new Date(iso).getTime(); 83 + const m = Math.floor(diff / 60000); 84 + const h = Math.floor(m / 60); 85 + const d = Math.floor(h / 24); 86 + if (d > 0) return `${d}d ago`; 87 + if (h > 0) return `${h}h ago`; 88 + if (m > 0) return `${m}m ago`; 89 + return "just now"; 90 + } 92 91 </script> 93 92 94 93 <style scoped> 95 - .prs-view { 96 - padding-bottom: 32px; 97 - } 94 + .prs-view { 95 + padding-bottom: 32px; 96 + } 98 97 99 - .loading-center { 100 - display: flex; 101 - justify-content: center; 102 - padding: 48px 0; 103 - } 98 + .loading-center { 99 + display: flex; 100 + justify-content: center; 101 + padding: 48px 0; 102 + } 104 103 105 - .filters-row { 106 - display: flex; 107 - gap: 6px; 108 - padding: 12px 16px 8px; 109 - } 104 + .filters-row { 105 + display: flex; 106 + gap: 6px; 107 + padding: 12px 16px 8px; 108 + } 110 109 111 - .filter-chip { 112 - --background: var(--t-surface-raised); 113 - --color: var(--t-text-secondary); 114 - border: 1px solid var(--t-border); 115 - font-size: 13px; 116 - margin: 0; 117 - cursor: pointer; 118 - } 110 + .filter-chip { 111 + --background: var(--t-surface-raised); 112 + --color: var(--t-text-secondary); 113 + border: 1px solid var(--t-border); 114 + font-size: 13px; 115 + margin: 0; 116 + cursor: pointer; 117 + } 119 118 120 - .filter-chip.active { 121 - --background: var(--t-accent-dim); 122 - --color: var(--t-accent); 123 - border-color: var(--t-accent); 124 - } 119 + .filter-chip.active { 120 + --background: var(--t-accent-dim); 121 + --color: var(--t-accent); 122 + border-color: var(--t-accent); 123 + } 125 124 126 - .pr-list { 127 - background: transparent; 128 - padding: 0; 129 - } 125 + .pr-list { 126 + background: transparent; 127 + padding: 0; 128 + } 130 129 131 - .pr-item { 132 - --background: transparent; 133 - --padding-start: 16px; 134 - --inner-padding-end: 12px; 135 - } 130 + .pr-item { 131 + --background: transparent; 132 + --padding-start: 16px; 133 + --inner-padding-end: 12px; 134 + } 136 135 137 - .status-icon { 138 - display: flex; 139 - align-items: center; 140 - justify-content: center; 141 - width: 28px; 142 - height: 28px; 143 - border-radius: 50%; 144 - font-size: 14px; 145 - margin-right: 10px; 146 - flex-shrink: 0; 147 - } 136 + .status-icon { 137 + display: flex; 138 + align-items: center; 139 + justify-content: center; 140 + width: 28px; 141 + height: 28px; 142 + border-radius: 50%; 143 + font-size: 14px; 144 + margin-right: 10px; 145 + flex-shrink: 0; 146 + } 148 147 149 - .status-icon.open { 150 - color: var(--t-accent); 151 - background: var(--t-accent-dim); 152 - } 153 - .status-icon.merged { 154 - color: var(--t-purple); 155 - background: rgba(167, 139, 250, 0.1); 156 - } 157 - .status-icon.closed { 158 - color: var(--t-text-muted); 159 - background: var(--t-surface-raised); 160 - } 148 + .status-icon.open { 149 + color: var(--t-accent); 150 + background: var(--t-accent-dim); 151 + } 152 + .status-icon.merged { 153 + color: var(--t-purple); 154 + background: rgba(167, 139, 250, 0.1); 155 + } 156 + .status-icon.closed { 157 + color: var(--t-text-muted); 158 + background: var(--t-surface-raised); 159 + } 161 160 162 - .pr-label { 163 - white-space: normal; 164 - padding: 10px 0; 165 - } 161 + .pr-label { 162 + white-space: normal; 163 + padding: 10px 0; 164 + } 166 165 167 - .pr-title { 168 - font-size: 13px; 169 - font-weight: 500; 170 - color: var(--t-text-primary); 171 - display: block; 172 - margin-bottom: 4px; 173 - line-height: 1.4; 174 - } 166 + .pr-title { 167 + font-size: 13px; 168 + font-weight: 500; 169 + color: var(--t-text-primary); 170 + display: block; 171 + margin-bottom: 4px; 172 + line-height: 1.4; 173 + } 175 174 176 - .pr-meta { 177 - display: flex; 178 - align-items: center; 179 - gap: 4px; 180 - font-size: 12px; 181 - color: var(--t-text-muted); 182 - flex-wrap: wrap; 183 - } 175 + .pr-meta { 176 + display: flex; 177 + align-items: center; 178 + gap: 4px; 179 + font-size: 12px; 180 + color: var(--t-text-muted); 181 + flex-wrap: wrap; 182 + } 184 183 185 - .mono { 186 - font-family: var(--t-mono); 187 - font-size: 11px; 188 - } 184 + .mono { 185 + font-family: var(--t-mono); 186 + font-size: 11px; 187 + } 189 188 190 - .mono:first-child { 191 - color: var(--t-accent); 192 - } 189 + .mono:first-child { 190 + color: var(--t-accent); 191 + } 193 192 194 - .branch { 195 - color: var(--t-text-secondary); 196 - } 193 + .branch { 194 + color: var(--t-text-secondary); 195 + } 197 196 198 - .sep { 199 - color: var(--t-border-strong); 200 - } 197 + .sep { 198 + color: var(--t-border-strong); 199 + } 201 200 202 - .status-badge { 203 - font-size: 11px; 204 - font-weight: 500; 205 - border-radius: 99px; 206 - padding: 2px 8px; 207 - text-transform: capitalize; 208 - } 201 + .status-badge { 202 + font-size: 11px; 203 + font-weight: 500; 204 + border-radius: 99px; 205 + padding: 2px 8px; 206 + text-transform: capitalize; 207 + } 209 208 210 - .status-badge.open { 211 - --background: var(--t-accent-dim); 212 - --color: var(--t-accent); 213 - } 214 - .status-badge.merged { 215 - --background: rgba(167, 139, 250, 0.1); 216 - --color: var(--t-purple); 217 - } 218 - .status-badge.closed { 219 - --background: transparent; 220 - --color: var(--t-text-muted); 221 - border: 1px solid var(--t-border-strong); 222 - } 209 + .status-badge.open { 210 + --background: var(--t-accent-dim); 211 + --color: var(--t-accent); 212 + } 213 + .status-badge.merged { 214 + --background: rgba(167, 139, 250, 0.1); 215 + --color: var(--t-purple); 216 + } 217 + .status-badge.closed { 218 + --background: transparent; 219 + --color: var(--t-text-muted); 220 + border: 1px solid var(--t-border-strong); 221 + } 223 222 </style>
-1
src/main.ts
··· 34 34 /* Theme variables */ 35 35 import "./theme/variables.css"; 36 36 37 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 38 37 persistQueryClient({ queryClient: queryClient as any, persister: createIdbPersister(), maxAge: 30 * 60 * 1000 }); 39 38 40 39 const app = createApp(App).use(IonicVue).use(router).use(createPinia()).use(VueQueryPlugin, { queryClient });
+8
src/mocks/repos.ts
··· 3 3 const MOCK_REPOS: RepoSummary[] = [ 4 4 { 5 5 atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/atproto-explorer", 6 + rkey: "atproto-explorer", 6 7 ownerDid: "did:plc:a1b2c3d4e5f6g7h8i9j0k1l2", 7 8 ownerHandle: "alice.tngl.sh", 8 9 name: "atproto-explorer", ··· 15 16 }, 16 17 { 17 18 atUri: "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.repo/twisted", 19 + rkey: "twisted", 18 20 ownerDid: "did:plc:p2cp5gopk7mgjegy9waligxd", 19 21 ownerHandle: "desertthunder.dev", 20 22 name: "twisted", ··· 27 29 }, 28 30 { 29 31 atUri: "at://did:plc:b2c3d4e5f6g7h8i9j0k1l2m3/sh.tangled.repo/git-log-pretty", 32 + rkey: "git-log-pretty", 30 33 ownerDid: "did:plc:b2c3d4e5f6g7h8i9j0k1l2m3", 31 34 ownerHandle: "bob.tngl.sh", 32 35 name: "git-log-pretty", ··· 39 42 }, 40 43 { 41 44 atUri: "at://did:plc:c3d4e5f6g7h8i9j0k1l2m3n4/sh.tangled.repo/iris-ui", 45 + rkey: "iris-ui", 42 46 ownerDid: "did:plc:c3d4e5f6g7h8i9j0k1l2m3n4", 43 47 ownerHandle: "clara.bsky.social", 44 48 name: "iris-ui", ··· 51 55 }, 52 56 { 53 57 atUri: "at://did:plc:e5f6g7h8i9j0k1l2m3n4o5p6/sh.tangled.repo/nix-atproto", 58 + rkey: "nix-atproto", 54 59 ownerDid: "did:plc:e5f6g7h8i9j0k1l2m3n4o5p6", 55 60 ownerHandle: "riku.tngl.sh", 56 61 name: "nix-atproto", ··· 63 68 }, 64 69 { 65 70 atUri: "at://did:plc:d4e5f6g7h8i9j0k1l2m3n4o5/sh.tangled.repo/tangled-cli", 71 + rkey: "tangled-cli", 66 72 ownerDid: "did:plc:d4e5f6g7h8i9j0k1l2m3n4o5", 67 73 ownerHandle: "dev.tangled.sh", 68 74 name: "tangled-cli", ··· 75 81 }, 76 82 { 77 83 atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/lexicon-validator", 84 + rkey: "lexicon-validator", 78 85 ownerDid: "did:plc:a1b2c3d4e5f6g7h8i9j0k1l2", 79 86 ownerHandle: "alice.tngl.sh", 80 87 name: "lexicon-validator", ··· 87 94 }, 88 95 { 89 96 atUri: "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.repo/bsky-feeds", 97 + rkey: "bsky-feeds", 90 98 ownerDid: "did:plc:p2cp5gopk7mgjegy9waligxd", 91 99 ownerHandle: "desertthunder.dev", 92 100 name: "bsky-feeds",
+107 -43
src/services/tangled/endpoints.ts
··· 1 1 /** 2 2 * Typed wrappers around XRPC queries to Tangled knots and the AT Protocol PDS. 3 3 * 4 - * All functions accept a Client instance so callers can route to the correct 5 - * knot host (via getKnotClient) or to the PDS (via pdsClient). 4 + * Knot endpoints use raw fetch so we can control query serialization for the 5 + * `repo=did:.../repoName` parameter. PDS endpoints also use raw fetch because 6 + * some `com.atproto.repo.*` calls are not typed in the installed packages. 6 7 * 7 8 * --- API Validation Notes (to verify against live endpoints) --- 8 9 * Knot XRPC base: https://<knot>/xrpc/<nsid> (e.g. us-west.tangled.sh) ··· 14 15 * 15 16 * Data routing: 16 17 * - Git data (tree, blob, log, branches, languages) → knot XRPC 17 - * - Repo metadata & profile → PDS com.atproto.repo.getRecord 18 + * - Repo metadata & profile → PDS com.atproto.repo.getRecord/listRecords 18 19 */ 19 20 20 - import type { Client } from "@atcute/client"; 21 - import type { 21 + import { 22 22 ShTangledRepoTree, 23 23 ShTangledRepoBlob, 24 24 ShTangledRepoGetDefaultBranch, ··· 38 38 ShTangledString, 39 39 } from "@atcute/tangled"; 40 40 import { throwOnXrpcError } from "@/services/atproto/client.js"; 41 - import { MalformedResponseError } from "@/core/errors/tangled.js"; 41 + import { MalformedResponseError, NotFoundError } from "@/core/errors/tangled.js"; 42 + 43 + type KnotParams = Record<string, string | number | boolean | undefined | Array<string | number | boolean>>; 44 + 45 + function encodeKnotQueryParam(key: string, value: string | number | boolean): string { 46 + const encodedValue = encodeURIComponent(String(value)); 47 + return `${encodeURIComponent(key)}=${key === "repo" ? encodedValue.replaceAll("%2F", "/") : encodedValue}`; 48 + } 49 + 50 + function buildKnotQuery(params: KnotParams): string { 51 + const pairs: string[] = []; 52 + 53 + for (const [key, rawValue] of Object.entries(params)) { 54 + if (rawValue === undefined) continue; 55 + 56 + if (Array.isArray(rawValue)) { 57 + for (const value of rawValue) { 58 + pairs.push(encodeKnotQueryParam(key, value)); 59 + } 60 + continue; 61 + } 62 + 63 + pairs.push(encodeKnotQueryParam(key, rawValue)); 64 + } 65 + 66 + return pairs.length > 0 ? `?${pairs.join("&")}` : ""; 67 + } 68 + 69 + export function buildKnotUrl(knotHost: string, nsid: string, params: KnotParams): string { 70 + return `https://${knotHost}/xrpc/${nsid}${buildKnotQuery(params)}`; 71 + } 72 + 73 + async function readKnotError(res: Response): Promise<never> { 74 + const contentType = res.headers.get("content-type") ?? ""; 75 + 76 + if (contentType.includes("application/json")) { 77 + const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; 78 + throwOnXrpcError(res.status, body.error ?? "Unknown", body.message); 79 + } 80 + 81 + const text = await res.text().catch(() => ""); 82 + throwOnXrpcError(res.status, "Unknown", text || undefined); 83 + } 84 + 85 + async function fetchKnotJson<T>(knotHost: string, nsid: string, params: KnotParams): Promise<T> { 86 + const res = await fetch(buildKnotUrl(knotHost, nsid, params)); 87 + if (!res.ok) return readKnotError(res); 88 + return res.json() as Promise<T>; 89 + } 90 + 91 + async function fetchKnotBytes(knotHost: string, nsid: string, params: KnotParams): Promise<Uint8Array> { 92 + const res = await fetch(buildKnotUrl(knotHost, nsid, params)); 93 + if (!res.ok) return readKnotError(res); 94 + return new Uint8Array(await res.arrayBuffer()); 95 + } 42 96 43 97 export async function fetchRepoTree( 44 - client: Client, 98 + knotHost: string, 45 99 params: ShTangledRepoTree.$params, 46 100 ): Promise<ShTangledRepoTree.$output> { 47 - const res = await client.get("sh.tangled.repo.tree", { params }); 48 - if (!res.ok) throwOnXrpcError(res.status, res.data.error, res.data.message); 49 - return res.data; 101 + return fetchKnotJson<ShTangledRepoTree.$output>(knotHost, "sh.tangled.repo.tree", params); 50 102 } 51 103 52 104 export async function fetchRepoBlob( 53 - client: Client, 105 + knotHost: string, 54 106 params: ShTangledRepoBlob.$params, 55 107 ): Promise<ShTangledRepoBlob.$output> { 56 - const res = await client.get("sh.tangled.repo.blob", { params }); 57 - if (!res.ok) throwOnXrpcError(res.status, res.data.error, res.data.message); 58 - return res.data; 108 + return fetchKnotJson<ShTangledRepoBlob.$output>(knotHost, "sh.tangled.repo.blob", params); 59 109 } 60 110 61 111 export async function fetchDefaultBranch( 62 - client: Client, 112 + knotHost: string, 63 113 params: ShTangledRepoGetDefaultBranch.$params, 64 114 ): Promise<ShTangledRepoGetDefaultBranch.$output> { 65 - const res = await client.get("sh.tangled.repo.getDefaultBranch", { params }); 66 - if (!res.ok) throwOnXrpcError(res.status, res.data.error, res.data.message); 67 - return res.data; 115 + return fetchKnotJson<ShTangledRepoGetDefaultBranch.$output>(knotHost, "sh.tangled.repo.getDefaultBranch", params); 68 116 } 69 117 70 118 export async function fetchLanguages( 71 - client: Client, 119 + knotHost: string, 72 120 params: ShTangledRepoLanguages.$params, 73 121 ): Promise<ShTangledRepoLanguages.$output> { 74 - const res = await client.get("sh.tangled.repo.languages", { params }); 75 - if (!res.ok) throwOnXrpcError(res.status, res.data.error, res.data.message); 76 - return res.data; 122 + return fetchKnotJson<ShTangledRepoLanguages.$output>(knotHost, "sh.tangled.repo.languages", params); 77 123 } 78 124 79 125 /** ··· 82 128 * the live API. Expected: newline-delimited JSON or git log text. 83 129 */ 84 130 export async function fetchRepoLog( 85 - client: Client, 131 + knotHost: string, 86 132 params: { repo: string; ref: string; path?: string; limit?: number; cursor?: string }, 87 133 ): Promise<string> { 88 - const res = await client.get("sh.tangled.repo.log", { params, as: "bytes" }); 89 - if (!res.ok) throwOnXrpcError(res.status, (res.data as { error: string }).error); 90 - return new TextDecoder().decode(res.data as Uint8Array); 134 + return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.log", params)); 91 135 } 92 136 93 137 /** ··· 95 139 * for the normalizer to parse once the live format is confirmed. 96 140 */ 97 141 export async function fetchRepoBranches( 98 - client: Client, 142 + knotHost: string, 99 143 params: { repo: string; limit?: number; cursor?: string }, 100 144 ): Promise<string> { 101 - const res = await client.get("sh.tangled.repo.branches", { params, as: "bytes" }); 102 - if (!res.ok) throwOnXrpcError(res.status, (res.data as { error: string }).error); 103 - return new TextDecoder().decode(res.data as Uint8Array); 145 + return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.branches", params)); 104 146 } 105 147 106 148 /** Tag list. Wire format is a raw blob — decoded text returned for normalizer. */ 107 - export async function fetchRepoTags(client: Client, params: ShTangledRepoTags.$params): Promise<string> { 108 - const res = await client.get("sh.tangled.repo.tags", { params, as: "bytes" }); 109 - if (!res.ok) throwOnXrpcError(res.status, (res.data as { error: string }).error); 110 - return new TextDecoder().decode(res.data as Uint8Array); 149 + export async function fetchRepoTags(knotHost: string, params: ShTangledRepoTags.$params): Promise<string> { 150 + return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.tags", params)); 111 151 } 112 152 113 153 /** Diff for a ref. Wire format is a raw blob — patch text. */ 114 - export async function fetchRepoDiff(client: Client, params: ShTangledRepoDiff.$params): Promise<string> { 115 - const res = await client.get("sh.tangled.repo.diff", { params, as: "bytes" }); 116 - if (!res.ok) throwOnXrpcError(res.status, (res.data as { error: string }).error); 117 - return new TextDecoder().decode(res.data as Uint8Array); 154 + export async function fetchRepoDiff(knotHost: string, params: ShTangledRepoDiff.$params): Promise<string> { 155 + return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.diff", params)); 118 156 } 119 157 120 158 /** Comparison between two revisions. Wire format is a raw blob — patch text. */ 121 - export async function fetchRepoCompare(client: Client, params: ShTangledRepoCompare.$params): Promise<string> { 122 - const res = await client.get("sh.tangled.repo.compare", { params, as: "bytes" }); 123 - if (!res.ok) throwOnXrpcError(res.status, (res.data as { error: string }).error); 124 - return new TextDecoder().decode(res.data as Uint8Array); 159 + export async function fetchRepoCompare(knotHost: string, params: ShTangledRepoCompare.$params): Promise<string> { 160 + return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.compare", params)); 125 161 } 126 162 127 163 type GetRecordResponse<T> = { uri: string; cid: string; value: T }; ··· 157 193 return getRecord<ShTangledActorProfile.Main>(pds, did, "sh.tangled.actor.profile", "self"); 158 194 } 159 195 196 + /** 197 + * Fetch a repo record by its PDS record key. 198 + * This is distinct from the repo's `name`, which is the identifier used by 199 + * knot endpoints in the `did:.../repoName` format. 200 + */ 160 201 export async function fetchRepoRecord( 161 202 pds: string, 162 203 did: string, 204 + rkey: string, 205 + ): Promise<GetRecordResponse<ShTangledRepo.Main>> { 206 + return getRecord<ShTangledRepo.Main>(pds, did, "sh.tangled.repo", rkey); 207 + } 208 + 209 + /** 210 + * Fetch a repo record by matching on the record's `name` field. 211 + * Use this when the UI route or knot API identifies a repo by repo name rather 212 + * than by the underlying AT Protocol record key. 213 + */ 214 + export async function fetchRepoRecordByName( 215 + pds: string, 216 + did: string, 163 217 repoName: string, 164 218 ): Promise<GetRecordResponse<ShTangledRepo.Main>> { 165 - return getRecord<ShTangledRepo.Main>(pds, did, "sh.tangled.repo", repoName); 219 + let cursor: string | undefined; 220 + 221 + for (;;) { 222 + const response = await listRepoRecords(pds, did, 100, cursor); 223 + const record = response.records.find((entry) => entry.value.name === repoName); 224 + if (record) return record; 225 + if (!response.cursor) break; 226 + cursor = response.cursor; 227 + } 228 + 229 + throw new NotFoundError(`Repository ${repoName}`); 166 230 } 167 231 168 232 export async function fetchIssueRecord(
+4 -3
src/services/tangled/normalizers.ts
··· 27 27 import { getAtUriRkey } from "./uris.js"; 28 28 29 29 function modeToFileKind(mode: string): RepoFile["type"] { 30 - if (mode.startsWith("04")) return "dir"; 31 - if (mode === "160000") return "submodule"; 30 + const normalizedMode = mode.replace(/^0+/, ""); 31 + if (normalizedMode === "40000") return "dir"; 32 + if (normalizedMode === "160000") return "submodule"; 32 33 return "file"; 33 34 } 34 35 ··· 169 170 ): RepoSummary { 170 171 return { 171 172 atUri, 173 + rkey: getAtUriRkey(atUri), 172 174 ownerDid, 173 175 ownerHandle, 174 176 name: record.name, ··· 377 379 did, 378 380 handle, 379 381 displayName, 380 - avatar: `https://avatar.tangled.sh/${did}`, 381 382 bio: record.description, 382 383 location: record.location, 383 384 pronouns: record.pronouns,
+48 -28
src/services/tangled/queries.ts
··· 16 16 import { useQuery } from "@tanstack/vue-query"; 17 17 import { computed, toValue } from "vue"; 18 18 import type { MaybeRef } from "vue"; 19 - import { getKnotClient } from "@/services/atproto/client.js"; 20 19 import type { FollowedUserSummary } from "@/domain/models/follow.js"; 21 20 import type { StringSummary } from "@/domain/models/string.js"; 22 21 import { ··· 30 29 fetchRepoDiff, 31 30 fetchRepoCompare, 32 31 fetchActorProfile, 33 - fetchRepoRecord, 32 + fetchRepoRecordByName, 34 33 fetchIssueRecord, 35 34 fetchPullRecord, 36 35 listRepoRecords, ··· 71 70 72 71 const MIN = 60_000; 73 72 73 + function hasText(value: MaybeRef<string | undefined>): boolean { 74 + return !!toValue(value)?.trim(); 75 + } 76 + 77 + function isEnabled(required: boolean, enabled?: MaybeRef<boolean>): boolean { 78 + return required && (enabled === undefined || !!toValue(enabled)); 79 + } 80 + 74 81 /** Resolved identity: DID + PDS hostname for an AT Protocol handle. */ 75 82 export type Identity = { did: string; pds: string }; 76 83 ··· 79 86 * Result is cached for 10 minutes (handles rarely change). 80 87 */ 81 88 export function useIdentity(handle: MaybeRef<string>, options: { enabled?: MaybeRef<boolean> } = {}) { 89 + const normalizedHandle = computed(() => toValue(handle).trim()); 90 + 82 91 return useQuery({ 83 - queryKey: computed(() => ["identity", toValue(handle)]), 92 + queryKey: computed(() => ["identity", normalizedHandle.value]), 84 93 queryFn: async (): Promise<Identity> => { 85 - const did = await resolveHandle(toValue(handle)); 94 + const did = await resolveHandle(normalizedHandle.value); 86 95 const pds = await resolvePds(did); 87 96 return { did, pds }; 88 97 }, 89 - enabled: options.enabled, 98 + enabled: computed(() => isEnabled(hasText(normalizedHandle), options.enabled)), 90 99 staleTime: 10 * MIN, 91 100 gcTime: 60 * MIN, 92 101 }); ··· 103 112 return useQuery({ 104 113 queryKey: computed(() => ["tree", toValue(knotHost), toValue(repo), toValue(ref), toValue(path)]), 105 114 queryFn: () => 106 - fetchRepoTree(getKnotClient(toValue(knotHost)), { 115 + fetchRepoTree(toValue(knotHost), { 107 116 repo: toValue(repo), 108 117 ref: toValue(ref), 109 118 path: toValue(path), ··· 125 134 return useQuery({ 126 135 queryKey: computed(() => ["blob", toValue(knotHost), toValue(repo), toValue(ref), toValue(path)]), 127 136 queryFn: () => 128 - fetchRepoBlob(getKnotClient(toValue(knotHost)), { 137 + fetchRepoBlob(toValue(knotHost), { 129 138 repo: toValue(repo), 130 139 ref: toValue(ref), 131 140 path: toValue(path), ··· 145 154 return useQuery({ 146 155 queryKey: computed(() => ["defaultBranch", toValue(knotHost), toValue(repo)]), 147 156 queryFn: () => 148 - fetchDefaultBranch(getKnotClient(toValue(knotHost)), { repo: toValue(repo) }).then(normalizeDefaultBranch), 157 + fetchDefaultBranch(toValue(knotHost), { repo: toValue(repo) }).then(normalizeDefaultBranch), 149 158 enabled: options.enabled, 150 159 staleTime: 5 * MIN, 151 160 gcTime: 30 * MIN, ··· 162 171 return useQuery({ 163 172 queryKey: computed(() => ["languages", toValue(knotHost), toValue(repo), toValue(ref)]), 164 173 queryFn: () => 165 - fetchLanguages(getKnotClient(toValue(knotHost)), { repo: toValue(repo), ref: toValue(ref) }).then( 166 - normalizeLanguages, 167 - ), 174 + fetchLanguages(toValue(knotHost), { repo: toValue(repo), ref: toValue(ref) }).then(normalizeLanguages), 168 175 enabled: options.enabled, 169 176 staleTime: 5 * MIN, 170 177 gcTime: 30 * MIN, ··· 193 200 toValue(options.cursor), 194 201 ]), 195 202 queryFn: () => 196 - fetchRepoLog(getKnotClient(toValue(knotHost)), { 203 + fetchRepoLog(toValue(knotHost), { 197 204 repo: toValue(repo), 198 205 ref: toValue(ref), 199 206 path: toValue(options.path), ··· 216 223 return useQuery({ 217 224 queryKey: computed(() => ["branches", toValue(knotHost), toValue(repo)]), 218 225 queryFn: () => 219 - fetchRepoBranches(getKnotClient(toValue(knotHost)), { repo: toValue(repo) }).then((raw) => 226 + fetchRepoBranches(toValue(knotHost), { repo: toValue(repo) }).then((raw) => 220 227 normalizeBranchesText(raw, toValue(defaultBranch)), 221 228 ), 222 229 enabled: options.enabled, ··· 236 243 handle: MaybeRef<string>, 237 244 options: { enabled?: MaybeRef<boolean> } = {}, 238 245 ) { 246 + const normalizedPds = computed(() => toValue(pds).trim()); 247 + const normalizedDid = computed(() => toValue(did).trim()); 248 + const normalizedRepoName = computed(() => toValue(repoName).trim()); 249 + 239 250 return useQuery({ 240 - queryKey: computed(() => ["repoRecord", toValue(pds), toValue(did), toValue(repoName)]), 251 + queryKey: computed(() => ["repoRecord", normalizedPds.value, normalizedDid.value, normalizedRepoName.value]), 241 252 queryFn: async () => { 242 - const { value: record, uri } = await fetchRepoRecord(toValue(pds), toValue(did), toValue(repoName)).then((r) => ({ 253 + const { value: record, uri } = await fetchRepoRecordByName( 254 + normalizedPds.value, 255 + normalizedDid.value, 256 + normalizedRepoName.value, 257 + ).then((r) => ({ 243 258 value: r.value, 244 259 uri: r.uri, 245 260 })); 246 261 return normalizeRepoRecord(record, toValue(did), toValue(handle), uri); 247 262 }, 248 - enabled: options.enabled, 263 + enabled: computed(() => 264 + isEnabled(hasText(normalizedPds) && hasText(normalizedDid) && hasText(normalizedRepoName), options.enabled), 265 + ), 249 266 staleTime: 5 * MIN, 250 267 gcTime: 30 * MIN, 251 268 }); ··· 258 275 handle: MaybeRef<string>, 259 276 options: { enabled?: MaybeRef<boolean> } = {}, 260 277 ) { 278 + const normalizedPds = computed(() => toValue(pds).trim()); 279 + const normalizedDid = computed(() => toValue(did).trim()); 280 + 261 281 return useQuery({ 262 - queryKey: computed(() => ["userRepos", toValue(pds), toValue(did)]), 282 + queryKey: computed(() => ["userRepos", normalizedPds.value, normalizedDid.value]), 263 283 queryFn: async () => { 264 - const { records } = await listRepoRecords(toValue(pds), toValue(did)); 284 + const { records } = await listRepoRecords(normalizedPds.value, normalizedDid.value); 265 285 return records.map((r) => normalizeRepoRecord(r.value, toValue(did), toValue(handle), r.uri)); 266 286 }, 267 - enabled: options.enabled, 287 + enabled: computed(() => isEnabled(hasText(normalizedPds) && hasText(normalizedDid), options.enabled)), 268 288 staleTime: 5 * MIN, 269 289 gcTime: 30 * MIN, 270 290 }); ··· 278 298 displayName?: MaybeRef<string | undefined>, 279 299 options: { enabled?: MaybeRef<boolean> } = {}, 280 300 ) { 301 + const normalizedPds = computed(() => toValue(pds).trim()); 302 + const normalizedDid = computed(() => toValue(did).trim()); 303 + 281 304 return useQuery({ 282 - queryKey: computed(() => ["actorProfile", toValue(pds), toValue(did)]), 305 + queryKey: computed(() => ["actorProfile", normalizedPds.value, normalizedDid.value]), 283 306 queryFn: async () => { 284 - const { value } = await fetchActorProfile(toValue(pds), toValue(did)); 307 + const { value } = await fetchActorProfile(normalizedPds.value, normalizedDid.value); 285 308 return normalizeActorProfile(value, toValue(did), toValue(handle), toValue(displayName)); 286 309 }, 287 - enabled: options.enabled, 310 + enabled: computed(() => isEnabled(hasText(normalizedPds) && hasText(normalizedDid), options.enabled)), 288 311 staleTime: 10 * MIN, 289 312 gcTime: 60 * MIN, 290 313 }); ··· 299 322 return useQuery({ 300 323 queryKey: computed(() => ["tags", toValue(knotHost), toValue(repo)]), 301 324 queryFn: () => 302 - fetchRepoTags(getKnotClient(toValue(knotHost)), { repo: toValue(repo) }).then((raw) => 303 - raw.trim().split("\n").filter(Boolean), 304 - ), 325 + fetchRepoTags(toValue(knotHost), { repo: toValue(repo) }).then((raw) => raw.trim().split("\n").filter(Boolean)), 305 326 enabled: options.enabled, 306 327 staleTime: 2 * MIN, 307 328 gcTime: 10 * MIN, ··· 317 338 ) { 318 339 return useQuery({ 319 340 queryKey: computed(() => ["diff", toValue(knotHost), toValue(repo), toValue(ref)]), 320 - queryFn: () => fetchRepoDiff(getKnotClient(toValue(knotHost)), { repo: toValue(repo), ref: toValue(ref) }), 341 + queryFn: () => fetchRepoDiff(toValue(knotHost), { repo: toValue(repo), ref: toValue(ref) }), 321 342 enabled: options.enabled, 322 343 staleTime: 5 * MIN, 323 344 gcTime: 30 * MIN, ··· 335 356 return useQuery({ 336 357 queryKey: computed(() => ["compare", toValue(knotHost), toValue(repo), toValue(rev1), toValue(rev2)]), 337 358 queryFn: () => 338 - fetchRepoCompare(getKnotClient(toValue(knotHost)), { 359 + fetchRepoCompare(toValue(knotHost), { 339 360 repo: toValue(repo), 340 361 rev1: toValue(rev1), 341 362 rev2: toValue(rev2), ··· 471 492 return { 472 493 did: subject.did, 473 494 handle: subject.handle, 474 - avatar: `https://avatar.tangled.sh/${subject.did}`, 475 495 followAtUri: follow.atUri, 476 496 followedAt: follow.createdAt, 477 497 };
+46 -1
tests/unit/tangled-normalizers.spec.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 - import { buildIssueCommentThread } from "@/services/tangled/normalizers.js"; 2 + import { buildKnotUrl } from "@/services/tangled/endpoints.js"; 3 + import { buildIssueCommentThread, normalizeRepoRecord, normalizeTree } from "@/services/tangled/normalizers.js"; 3 4 import { getAtUriRkey, parseAtUri } from "@/services/tangled/uris.js"; 4 5 import type { IssueComment } from "@/domain/models/comment.js"; 5 6 ··· 23 24 24 25 expect(parseAtUri(uri)).toEqual({ did: "did:plc:abc123", collection: "sh.tangled.repo.issue", rkey: "42" }); 25 26 expect(getAtUriRkey(uri)).toBe("42"); 27 + }); 28 + 29 + it("preserves the repo record rkey separately from the display name", () => { 30 + const repo = normalizeRepoRecord( 31 + { 32 + $type: "sh.tangled.repo", 33 + name: "Writer", 34 + knot: "us-west.host.bsky.network", 35 + createdAt: "2026-03-22T10:00:00Z", 36 + }, 37 + "did:plc:abc123", 38 + "alice.test", 39 + "at://did:plc:abc123/sh.tangled.repo/writer-app", 40 + ); 41 + 42 + expect(repo.name).toBe("Writer"); 43 + expect(repo.rkey).toBe("writer-app"); 44 + }); 45 + 46 + it("preserves the repo slash in knot XRPC query strings", () => { 47 + const url = buildKnotUrl("knot1.tangled.sh", "sh.tangled.repo.getDefaultBranch", { 48 + repo: "did:plc:xg2vq45muivyy3xwatcehspu/writer", 49 + }); 50 + 51 + expect(url).toContain("repo=did%3Aplc%3Axg2vq45muivyy3xwatcehspu/writer"); 52 + expect(url).not.toContain("%2Fwriter"); 53 + }); 54 + 55 + it("derives file kinds from zero-padded git modes", () => { 56 + const files = normalizeTree({ 57 + files: [ 58 + { mode: "0040000", name: ".github", size: 75, last_commit: { hash: "a", message: "dir", when: "2026-03-23T00:00:00Z" } }, 59 + { mode: "0100644", name: "README.md", size: 3126, last_commit: { hash: "b", message: "file", when: "2026-03-23T00:00:00Z" } }, 60 + { mode: "0160000", name: "vendor/lib", size: 0, last_commit: { hash: "c", message: "submodule", when: "2026-03-23T00:00:00Z" } }, 61 + ], 62 + lastCommit: { hash: "a", message: "dir", when: "2026-03-23T00:00:00Z", author: { name: "Test", email: "test@example.com", when: "" } }, 63 + ref: "main", 64 + }); 65 + 66 + expect(files.map((file) => [file.name, file.type])).toEqual([ 67 + [".github", "dir"], 68 + ["README.md", "file"], 69 + ["vendor/lib", "submodule"], 70 + ]); 26 71 }); 27 72 }); 28 73
+5 -2
tsconfig.node.json
··· 1 1 { 2 2 "compilerOptions": { 3 3 "composite": true, 4 + "target": "ESNext", 5 + "lib": ["ESNext"], 4 6 "module": "nodenext", 5 7 "moduleResolution": "nodenext", 6 8 "allowSyntheticDefaultImports": true, 9 + "esModuleInterop": true, 7 10 "skipLibCheck": true, 8 - "types": ["@atcute/bluesky", "@atcute/tangled"] 11 + "types": ["node", "vitest"] 9 12 }, 10 - "include": ["vite.config.ts"] 13 + "include": ["vite.config.ts", "cypress.config.ts", "capacitor.config.ts"] 11 14 }