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: parse repo commit logs

+335 -192
+9 -1
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 - <div class="avatar-fallback" :style="{ background: avatarColor(user.handle) }"> 6 + <img v-if="user.avatar" :src="user.avatar" :alt="`${user.handle} avatar`" class="avatar-image" /> 7 + <div v-else class="avatar-fallback" :style="{ background: avatarColor(user.handle) }"> 7 8 {{ initials(user.handle) }} 8 9 </div> 9 10 </ion-avatar> ··· 77 78 flex-shrink: 0; 78 79 border-radius: var(--t-radius-sm); 79 80 overflow: hidden; 81 + } 82 + 83 + .avatar-image { 84 + width: 100%; 85 + height: 100%; 86 + object-fit: cover; 87 + display: block; 80 88 } 81 89 82 90 .avatar-fallback {
+1 -1
src/features/home/HomePage.vue
··· 15 15 16 16 <section class="hero"> 17 17 <p class="eyebrow">Profile Browser</p> 18 - <h1 class="hero-title">Jump straight to a Tangled profile or browse that handle's repos.</h1> 18 + <h1 class="hero-title">Jump straight to a Tangled profile or repo.</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 21 21 browse their public repositories here.
+10 -6
src/features/profile/UserProfilePage.vue
··· 24 24 <template v-else> 25 25 <div class="profile-header"> 26 26 <ion-avatar class="avatar"> 27 - <div class="avatar-fallback" :style="{ background: avatarColor(handle) }"> 27 + <img v-if="profile?.avatar" :src="profile.avatar" :alt="`${handle} avatar`" class="avatar-image" /> 28 + <div v-else class="avatar-fallback" :style="{ background: avatarColor(handle) }"> 28 29 {{ initials(handle) }} 29 30 </div> 30 31 </ion-avatar> ··· 258 259 } 259 260 260 261 function displayLink(url: string): string { 261 - try { 262 - return new URL(url).hostname; 263 - } catch { 264 - return url; 265 - } 262 + return url.trim().replace(/^[a-z]+:\/\//i, ""); 266 263 } 267 264 268 265 const PALETTE = ["#22d3ee", "#a78bfa", "#34d399", "#fbbf24", "#f87171", "#fb923c", "#60a5fa"]; ··· 301 298 flex-shrink: 0; 302 299 border-radius: var(--t-radius-md); 303 300 overflow: hidden; 301 + } 302 + 303 + .avatar-image { 304 + width: 100%; 305 + height: 100%; 306 + object-fit: cover; 307 + display: block; 304 308 } 305 309 306 310 .avatar-fallback {
+172 -172
src/features/repo/RepoOverview.vue
··· 48 48 </div> 49 49 50 50 <!-- Recent Commits --> 51 - <div v-if="commits && commits.length" class="section"> 51 + <div v-if="commits && commits.length > 0" 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"> ··· 62 62 </template> 63 63 64 64 <script setup lang="ts"> 65 - import { computed } from "vue"; 66 - import { IonIcon, IonChip } from "@ionic/vue"; 67 - import { starOutline, gitBranchOutline, codeOutline, documentOutline } from "ionicons/icons"; 68 - import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 69 - import EmptyState from "@/components/common/EmptyState.vue"; 70 - import type { RepoDetail } from "@/domain/models/repo.js"; 71 - import type { CommitEntry } from "@/services/tangled/queries.js"; 65 + import { computed } from "vue"; 66 + import { IonIcon, IonChip } from "@ionic/vue"; 67 + import { starOutline, gitBranchOutline, codeOutline, documentOutline } from "ionicons/icons"; 68 + import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 69 + import EmptyState from "@/components/common/EmptyState.vue"; 70 + import type { RepoDetail } from "@/domain/models/repo.js"; 71 + import type { CommitEntry } from "@/services/tangled/queries.js"; 72 72 73 - const props = defineProps<{ repo: RepoDetail; commits?: CommitEntry[] }>(); 73 + const props = defineProps<{ repo: RepoDetail; commits?: CommitEntry[] }>(); 74 74 75 - function relativeTime(iso: string): string { 76 - const d = new Date(iso); 77 - if (isNaN(d.getTime())) return iso; 78 - const diff = Date.now() - d.getTime(); 79 - const m = Math.floor(diff / 60000); 80 - const h = Math.floor(m / 60); 81 - const days = Math.floor(h / 24); 82 - if (days > 0) return `${days}d ago`; 83 - if (h > 0) return `${h}h ago`; 84 - if (m > 0) return `${m}m ago`; 85 - return "just now"; 86 - } 75 + function relativeTime(iso: string): string { 76 + const d = new Date(iso); 77 + if (isNaN(d.getTime())) return iso; 78 + const diff = Date.now() - d.getTime(); 79 + const m = Math.floor(diff / 60000); 80 + const h = Math.floor(m / 60); 81 + const days = Math.floor(h / 24); 82 + if (days > 0) return `${days}d ago`; 83 + if (h > 0) return `${h}h ago`; 84 + if (m > 0) return `${m}m ago`; 85 + return "just now"; 86 + } 87 87 88 - const LANG_COLORS: Record<string, string> = { 89 - TypeScript: "#3178c6", 90 - JavaScript: "#f7df1e", 91 - Go: "#00add8", 92 - Python: "#3572A5", 93 - Rust: "#dea584", 94 - Nix: "#7ebae4", 95 - Ruby: "#cc342d", 96 - CSS: "#563d7c", 97 - HTML: "#e34c26", 98 - }; 88 + const LANG_COLORS: Record<string, string> = { 89 + TypeScript: "#3178c6", 90 + JavaScript: "#f7df1e", 91 + Go: "#00add8", 92 + Python: "#3572A5", 93 + Rust: "#dea584", 94 + Nix: "#7ebae4", 95 + Ruby: "#cc342d", 96 + CSS: "#563d7c", 97 + HTML: "#e34c26", 98 + }; 99 99 100 - function langColor(lang: string): string { 101 - return LANG_COLORS[lang] ?? "var(--t-text-muted)"; 102 - } 100 + function langColor(lang: string): string { 101 + return LANG_COLORS[lang] ?? "var(--t-text-muted)"; 102 + } 103 103 104 - const langEntries = computed(() => Object.entries(props.repo.languages ?? {}).sort(([, a], [, b]) => b - a)); 104 + const langEntries = computed(() => Object.entries(props.repo.languages ?? {}).sort(([, a], [, b]) => b - a)); 105 105 </script> 106 106 107 107 <style scoped> 108 - .overview { 109 - padding-bottom: 32px; 110 - } 108 + .overview { 109 + padding-bottom: 32px; 110 + } 111 111 112 - .stats-row { 113 - display: flex; 114 - gap: 20px; 115 - padding: 16px 16px 12px; 116 - border-bottom: 1px solid var(--t-border); 117 - } 112 + .stats-row { 113 + display: flex; 114 + gap: 20px; 115 + padding: 16px 16px 12px; 116 + border-bottom: 1px solid var(--t-border); 117 + } 118 118 119 - .stat-item { 120 - display: flex; 121 - align-items: center; 122 - gap: 5px; 123 - } 119 + .stat-item { 120 + display: flex; 121 + align-items: center; 122 + gap: 5px; 123 + } 124 124 125 - .stat-icon { 126 - font-size: 14px; 127 - } 125 + .stat-icon { 126 + font-size: 14px; 127 + } 128 128 129 - .stat-icon.amber { 130 - color: var(--t-amber); 131 - } 132 - .stat-icon.accent { 133 - color: var(--t-accent); 134 - } 135 - .stat-icon.muted { 136 - color: var(--t-text-muted); 137 - } 129 + .stat-icon.amber { 130 + color: var(--t-amber); 131 + } 132 + .stat-icon.accent { 133 + color: var(--t-accent); 134 + } 135 + .stat-icon.muted { 136 + color: var(--t-text-muted); 137 + } 138 138 139 - .stat-value { 140 - font-size: 13px; 141 - font-weight: 600; 142 - color: var(--t-text-primary); 143 - } 139 + .stat-value { 140 + font-size: 13px; 141 + font-weight: 600; 142 + color: var(--t-text-primary); 143 + } 144 144 145 - .stat-value.mono { 146 - font-family: var(--t-mono); 147 - font-size: 12px; 148 - } 145 + .stat-value.mono { 146 + font-family: var(--t-mono); 147 + font-size: 12px; 148 + } 149 149 150 - .stat-label { 151 - font-size: 12px; 152 - color: var(--t-text-muted); 153 - } 150 + .stat-label { 151 + font-size: 12px; 152 + color: var(--t-text-muted); 153 + } 154 154 155 - .repo-description { 156 - font-size: 14px; 157 - color: var(--t-text-secondary); 158 - margin: 14px 16px 0; 159 - line-height: 1.55; 160 - } 155 + .repo-description { 156 + font-size: 14px; 157 + color: var(--t-text-secondary); 158 + margin: 14px 16px 0; 159 + line-height: 1.55; 160 + } 161 161 162 - .topics-row { 163 - display: flex; 164 - flex-wrap: wrap; 165 - gap: 6px; 166 - padding: 12px 16px 0; 167 - } 162 + .topics-row { 163 + display: flex; 164 + flex-wrap: wrap; 165 + gap: 6px; 166 + padding: 12px 16px 0; 167 + } 168 168 169 - .topic-chip { 170 - --background: var(--t-accent-dim); 171 - --color: var(--t-accent); 172 - border: 1px solid var(--t-border-strong); 173 - font-size: 12px; 174 - height: 26px; 175 - margin: 0; 176 - } 169 + .topic-chip { 170 + --background: var(--t-accent-dim); 171 + --color: var(--t-accent); 172 + border: 1px solid var(--t-border-strong); 173 + font-size: 12px; 174 + height: 26px; 175 + margin: 0; 176 + } 177 177 178 - .section { 179 - margin-top: 20px; 180 - } 178 + .section { 179 + margin-top: 20px; 180 + } 181 181 182 - .section-label { 183 - font-size: 11px; 184 - font-weight: 600; 185 - text-transform: uppercase; 186 - letter-spacing: 0.07em; 187 - color: var(--t-text-muted); 188 - margin: 0 16px 10px; 189 - } 182 + .section-label { 183 + font-size: 11px; 184 + font-weight: 600; 185 + text-transform: uppercase; 186 + letter-spacing: 0.07em; 187 + color: var(--t-text-muted); 188 + margin: 0 16px 10px; 189 + } 190 190 191 - .lang-list { 192 - display: flex; 193 - flex-direction: column; 194 - gap: 8px; 195 - padding: 0 16px; 196 - } 191 + .lang-list { 192 + display: flex; 193 + flex-direction: column; 194 + gap: 8px; 195 + padding: 0 16px; 196 + } 197 197 198 - .lang-row { 199 - display: flex; 200 - align-items: center; 201 - gap: 8px; 202 - } 198 + .lang-row { 199 + display: flex; 200 + align-items: center; 201 + gap: 8px; 202 + } 203 203 204 - .lang-dot { 205 - width: 10px; 206 - height: 10px; 207 - border-radius: 50%; 208 - flex-shrink: 0; 209 - } 204 + .lang-dot { 205 + width: 10px; 206 + height: 10px; 207 + border-radius: 50%; 208 + flex-shrink: 0; 209 + } 210 210 211 - .lang-name { 212 - font-size: 13px; 213 - color: var(--t-text-secondary); 214 - flex: 1; 215 - } 211 + .lang-name { 212 + font-size: 13px; 213 + color: var(--t-text-secondary); 214 + flex: 1; 215 + } 216 216 217 - .lang-pct { 218 - font-family: var(--t-mono); 219 - font-size: 12px; 220 - color: var(--t-text-muted); 221 - } 217 + .lang-pct { 218 + font-family: var(--t-mono); 219 + font-size: 12px; 220 + color: var(--t-text-muted); 221 + } 222 222 223 - .commit-list { 224 - display: flex; 225 - flex-direction: column; 226 - gap: 0; 227 - border: 1px solid var(--t-border); 228 - border-radius: var(--t-radius-md); 229 - margin: 0 16px; 230 - overflow: hidden; 231 - } 223 + .commit-list { 224 + display: flex; 225 + flex-direction: column; 226 + gap: 0; 227 + border: 1px solid var(--t-border); 228 + border-radius: var(--t-radius-md); 229 + margin: 0 16px; 230 + overflow: hidden; 231 + } 232 232 233 - .commit-row { 234 - display: flex; 235 - align-items: center; 236 - gap: 10px; 237 - padding: 8px 12px; 238 - border-bottom: 1px solid var(--t-border); 239 - } 233 + .commit-row { 234 + display: flex; 235 + align-items: center; 236 + gap: 10px; 237 + padding: 8px 12px; 238 + border-bottom: 1px solid var(--t-border); 239 + } 240 240 241 - .commit-row:last-child { 242 - border-bottom: none; 243 - } 241 + .commit-row:last-child { 242 + border-bottom: none; 243 + } 244 244 245 - .commit-hash { 246 - font-family: var(--t-mono); 247 - font-size: 11px; 248 - color: var(--t-accent); 249 - flex-shrink: 0; 250 - width: 52px; 251 - } 245 + .commit-hash { 246 + font-family: var(--t-mono); 247 + font-size: 11px; 248 + color: var(--t-accent); 249 + flex-shrink: 0; 250 + width: 52px; 251 + } 252 252 253 - .commit-message { 254 - font-size: 12px; 255 - color: var(--t-text-secondary); 256 - flex: 1; 257 - white-space: nowrap; 258 - overflow: hidden; 259 - text-overflow: ellipsis; 260 - } 253 + .commit-message { 254 + font-size: 12px; 255 + color: var(--t-text-secondary); 256 + flex: 1; 257 + white-space: nowrap; 258 + overflow: hidden; 259 + text-overflow: ellipsis; 260 + } 261 261 262 - .commit-when { 263 - font-size: 11px; 264 - color: var(--t-text-muted); 265 - flex-shrink: 0; 266 - } 262 + .commit-when { 263 + font-size: 11px; 264 + color: var(--t-text-muted); 265 + flex-shrink: 0; 266 + } 267 267 </style>
+14
src/services/tangled/endpoints.ts
··· 41 41 import { MalformedResponseError, NotFoundError } from "@/core/errors/tangled.js"; 42 42 43 43 type KnotParams = Record<string, string | number | boolean | undefined | Array<string | number | boolean>>; 44 + type BlueskyProfileResponse = { did: string; handle: string; displayName?: string; avatar?: string }; 44 45 45 46 function encodeKnotQueryParam(key: string, value: string | number | boolean): string { 46 47 const encodedValue = encodeURIComponent(String(value)); ··· 191 192 did: string, 192 193 ): Promise<GetRecordResponse<ShTangledActorProfile.Main>> { 193 194 return getRecord<ShTangledActorProfile.Main>(pds, did, "sh.tangled.actor.profile", "self"); 195 + } 196 + 197 + export async function fetchBlueskyProfile(actor: string): Promise<BlueskyProfileResponse> { 198 + const url = new URL("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile"); 199 + url.searchParams.set("actor", actor); 200 + 201 + const res = await fetch(url.toString()); 202 + if (!res.ok) { 203 + const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; 204 + throwOnXrpcError(res.status, body.error ?? "Unknown", body.message); 205 + } 206 + 207 + return res.json() as Promise<BlueskyProfileResponse>; 194 208 } 195 209 196 210 /**
+54 -8
src/services/tangled/normalizers.ts
··· 102 102 103 103 if (text.startsWith("{")) { 104 104 try { 105 - return text 106 - .split("\n") 107 - .filter(Boolean) 108 - .map((line) => normalizeCommitObject(JSON.parse(line) as Record<string, unknown>)); 105 + const parsed = JSON.parse(text) as Record<string, unknown>; 106 + const commits = getCommitObjects(parsed); 107 + if (commits.length) { 108 + return commits.map((item) => normalizeCommitObject(item)); 109 + } 109 110 } catch { 110 - console.warn("Failed to parse log as newline-delimited JSON, falling back to other formats"); 111 + try { 112 + return text 113 + .split("\n") 114 + .filter(Boolean) 115 + .map((line) => normalizeCommitObject(JSON.parse(line) as Record<string, unknown>)); 116 + } catch { 117 + console.warn("Failed to parse log as JSON, falling back to other formats"); 118 + } 111 119 } 112 120 } 113 121 ··· 117 125 .map((line) => ({ hash: "", message: line, when: "" })); 118 126 } 119 127 128 + function getCommitObjects(obj: Record<string, unknown>): Array<Record<string, unknown>> { 129 + const collections = [obj.commits, obj.log, obj.entries]; 130 + 131 + for (const value of collections) { 132 + if (Array.isArray(value)) { 133 + return value.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null); 134 + } 135 + } 136 + 137 + if ("hash" in obj || "sha" in obj || "id" in obj || "message" in obj || "subject" in obj) { 138 + return [obj]; 139 + } 140 + 141 + return []; 142 + } 143 + 120 144 function normalizeCommitObject(obj: Record<string, unknown>): CommitEntry { 145 + const hash = normalizeCommitHash(obj.hash ?? obj.sha ?? obj.id) ?? ""; 146 + const shortHash = normalizeCommitHash(obj.shortHash) || (hash ? hash.slice(0, 7) : undefined); 147 + 121 148 return { 122 - hash: String(obj.hash ?? obj.sha ?? obj.id ?? ""), 123 - shortHash: obj.shortHash != null ? String(obj.shortHash) : undefined, 149 + hash, 150 + shortHash, 124 151 message: String(obj.message ?? obj.subject ?? ""), 125 152 when: String(obj.when ?? obj.date ?? obj.timestamp ?? ""), 126 153 authorName: obj.author ··· 134 161 ? obj.authorEmail 135 162 : undefined, 136 163 }; 164 + } 165 + 166 + function normalizeCommitHash(value: unknown): string | undefined { 167 + if (value == null) return undefined; 168 + if (typeof value === "string") return value || undefined; 169 + 170 + if (value instanceof Uint8Array) { 171 + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); 172 + } 173 + 174 + if (Array.isArray(value) && value.every((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 255)) { 175 + return value.map((byte) => Number(byte).toString(16).padStart(2, "0")).join(""); 176 + } 177 + 178 + return String(value) || undefined; 137 179 } 138 180 139 181 export type BranchEntry = { name: string; isDefault?: boolean }; ··· 374 416 did: string, 375 417 handle: string, 376 418 displayName?: string, 419 + avatar?: string, 377 420 ): UserSummary & { location?: string; pronouns?: string; links?: string[]; pinnedRepos?: string[] } { 421 + const links = record.links?.map((link) => link.trim()).filter(Boolean); 422 + 378 423 return { 379 424 did, 380 425 handle, 381 426 displayName, 427 + avatar, 382 428 bio: record.description, 383 429 location: record.location, 384 430 pronouns: record.pronouns, 385 - links: record.links, 431 + links, 386 432 pinnedRepos: record.pinnedRepositories, 387 433 }; 388 434 }
+25 -2
src/services/tangled/queries.ts
··· 29 29 fetchRepoDiff, 30 30 fetchRepoCompare, 31 31 fetchActorProfile, 32 + fetchBlueskyProfile, 32 33 fetchRepoRecordByName, 33 34 fetchIssueRecord, 34 35 fetchPullRecord, ··· 78 79 return required && (enabled === undefined || !!toValue(enabled)); 79 80 } 80 81 82 + async function resolveBlueskyProfile( 83 + record: Awaited<ReturnType<typeof fetchActorProfile>>["value"], 84 + did: string, 85 + ): Promise<{ displayName?: string; avatar?: string }> { 86 + if (!record.bluesky) return {}; 87 + 88 + try { 89 + const profile = await fetchBlueskyProfile(did); 90 + return { displayName: profile.displayName, avatar: profile.avatar }; 91 + } catch { 92 + return {}; 93 + } 94 + } 95 + 81 96 /** Resolved identity: DID + PDS hostname for an AT Protocol handle. */ 82 97 export type Identity = { did: string; pds: string }; 83 98 ··· 305 320 queryKey: computed(() => ["actorProfile", normalizedPds.value, normalizedDid.value]), 306 321 queryFn: async () => { 307 322 const { value } = await fetchActorProfile(normalizedPds.value, normalizedDid.value); 308 - return normalizeActorProfile(value, toValue(did), toValue(handle), toValue(displayName)); 323 + const bluesky = await resolveBlueskyProfile(value, normalizedDid.value); 324 + return normalizeActorProfile( 325 + value, 326 + toValue(did), 327 + toValue(handle), 328 + bluesky.displayName ?? toValue(displayName), 329 + bluesky.avatar, 330 + ); 309 331 }, 310 332 enabled: computed(() => isEnabled(hasText(normalizedPds) && hasText(normalizedDid), options.enabled)), 311 333 staleTime: 10 * MIN, ··· 483 505 484 506 try { 485 507 const { value } = await fetchActorProfile(subject.pds, subject.did); 508 + const bluesky = await resolveBlueskyProfile(value, subject.did); 486 509 return { 487 - ...normalizeActorProfile(value, subject.did, subject.handle), 510 + ...normalizeActorProfile(value, subject.did, subject.handle, bluesky.displayName, bluesky.avatar), 488 511 followAtUri: follow.atUri, 489 512 followedAt: follow.createdAt, 490 513 };
+49 -1
tests/unit/tangled-normalizers.spec.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 2 import { buildKnotUrl } from "@/services/tangled/endpoints.js"; 3 - import { buildIssueCommentThread, normalizeRepoRecord, normalizeTree } from "@/services/tangled/normalizers.js"; 3 + import { buildIssueCommentThread, normalizeLogText, normalizeRepoRecord, normalizeTree } from "@/services/tangled/normalizers.js"; 4 4 import { getAtUriRkey, parseAtUri } from "@/services/tangled/uris.js"; 5 5 import type { IssueComment } from "@/domain/models/comment.js"; 6 6 ··· 68 68 ["README.md", "file"], 69 69 ["vendor/lib", "submodule"], 70 70 ]); 71 + }); 72 + 73 + it("parses wrapped commit arrays from repo log payloads", () => { 74 + const commits = normalizeLogText( 75 + JSON.stringify({ 76 + commits: [ 77 + { 78 + hash: "60074765a75ecb6a763dcf82252ef4365187af21", 79 + shortHash: "6007476", 80 + message: "feat: persist sidebar state between reloads", 81 + when: "2026-03-21T14:59:03Z", 82 + author: { name: "Owais Jamil", email: "desertthunder.dev@gmail.com" }, 83 + }, 84 + ], 85 + }), 86 + ); 87 + 88 + expect(commits).toEqual([ 89 + { 90 + hash: "60074765a75ecb6a763dcf82252ef4365187af21", 91 + shortHash: "6007476", 92 + message: "feat: persist sidebar state between reloads", 93 + when: "2026-03-21T14:59:03Z", 94 + authorName: "Owais Jamil", 95 + authorEmail: "desertthunder.dev@gmail.com", 96 + }, 97 + ]); 98 + }); 99 + 100 + it("hex-encodes byte-array commit hashes", () => { 101 + const commits = normalizeLogText( 102 + JSON.stringify({ 103 + commits: [ 104 + { 105 + hash: [219, 149, 244, 86, 116, 134, 146, 69, 98, 104, 59, 177, 138, 231, 236, 43, 189, 85, 234, 95], 106 + message: "docs: update site", 107 + when: "2026-03-21T15:08:58Z", 108 + }, 109 + ], 110 + }), 111 + ); 112 + 113 + expect(commits[0]).toMatchObject({ 114 + hash: "db95f4567486924562683bb18ae7ec2bbd55ea5f", 115 + shortHash: "db95f45", 116 + message: "docs: update site", 117 + when: "2026-03-21T15:08:58Z", 118 + }); 71 119 }); 72 120 }); 73 121
+1 -1
vite.config.ts
··· 9 9 export default defineConfig({ 10 10 plugins: [vue(), legacy()], 11 11 resolve: { alias: { "@": path.resolve(__dirname, "./src") } }, 12 - test: { globals: true, environment: "jsdom" }, 12 + test: { globals: true, environment: "jsdom", watch: false, ui: false }, 13 13 });