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