diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 7462f77..0000000 --- a/.eslintignore +++ /dev/null @@ -1,27 +0,0 @@ -.DS_Store -node_modules -/coverage -/dist -/ios -/android - - -# local env files -.env.local -.env.*.local - -# Log files -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Editor directories and files -.idea -.vscode -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? - diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 07ce6c9..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,21 +0,0 @@ -module.exports = { - root: true, - env: { - node: true, - }, - extends: [ - "plugin:vue/vue3-essential", - "eslint:recommended", - "@vue/typescript/recommended", - ], - parserOptions: { - ecmaVersion: 2020, - }, - rules: { - "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", - "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", - "vue/no-deprecated-slot-attribute": "off", - "@typescript-eslint/no-explicit-any": "off", - "vue/max-template-depth": ["warn", { maxDepth: 4 }], - }, -}; diff --git a/.prettierrc b/.prettierrc index bc1e653..6db58ab 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1 @@ -{ - "tabWidth": 2, - "printWidth": 120, - "objectWrap": "collapse", - "bracketSameLine": true -} +{ "tabWidth": 2, "printWidth": 120, "objectWrap": "collapse", "bracketSameLine": true, "vueIndentScriptAndStyle": true } diff --git a/README.md b/README.md index 7e8f8ac..f2496c6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,18 @@ # Twisted A mobile client for [Tangled](https://tangled.org). + +## Development + +Run the mobile apps with Capacitor: + +```bash +pnpm cap run ios +pnpm cap run android +``` + +Or to test the web version: + +```bash +pnpm dev +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..0ae8c53 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,49 @@ +import js from "@eslint/js"; +import pluginVue from "eslint-plugin-vue"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +const isProduction = process.env.NODE_ENV === "production"; + +export default tseslint.config( + { + ignores: [ + "**/.DS_Store", + "**/node_modules/**", + "coverage/**", + "dist/**", + "ios/**", + "android/**", + ".env.local", + ".env.*.local", + "npm-debug.log*", + "yarn-debug.log*", + "yarn-error.log*", + "pnpm-debug.log*", + ".idea/**", + ".vscode/**", + "*.suo", + "*.ntvs*", + "*.njsproj", + "*.sln", + "*.sw?", + ], + }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended, ...pluginVue.configs["flat/essential"]], + files: ["**/*.{js,mjs,cjs,ts,mts,cts,vue}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { ...globals.browser, ...globals.node }, + parserOptions: { parser: tseslint.parser }, + }, + rules: { + "no-console": isProduction ? "warn" : "off", + "no-debugger": isProduction ? "warn" : "off", + "@typescript-eslint/no-explicit-any": "off", + "vue/no-deprecated-slot-attribute": "off", + }, + }, + { files: ["**/*.cjs"], languageOptions: { sourceType: "commonjs" } }, +); diff --git a/package.json b/package.json index 2867cdc..87e7f4b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "devDependencies": { "@capacitor/cli": "8.2.0", "@eslint/js": "10.0.1", + "@types/node": "25.5.0", "@vitejs/plugin-legacy": "^5.0.0", "@vitejs/plugin-vue": "^4.0.0", "@vue/test-utils": "^2.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f117e0..622b4ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@eslint/js': specifier: 10.0.1 version: 10.0.1(eslint@10.1.0) + '@types/node': + specifier: 25.5.0 + version: 25.5.0 '@vitejs/plugin-legacy': specifier: ^5.0.0 version: 5.4.3(terser@5.46.1)(vite@5.4.21(@types/node@25.5.0)(terser@5.46.1)) diff --git a/src/components/common/UserCard.vue b/src/components/common/UserCard.vue index 4489759..f3b7db2 100644 --- a/src/components/common/UserCard.vue +++ b/src/components/common/UserCard.vue @@ -3,8 +3,7 @@
- -
+
{{ initials(user.handle) }}
diff --git a/src/components/repo/FileTreeItem.vue b/src/components/repo/FileTreeItem.vue index 948e35a..730c2e3 100644 --- a/src/components/repo/FileTreeItem.vue +++ b/src/components/repo/FileTreeItem.vue @@ -1,10 +1,6 @@ diff --git a/src/main.ts b/src/main.ts index b3dac3d..39d1be7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,7 +34,6 @@ import "@ionic/vue/css/palettes/dark.system.css"; /* Theme variables */ import "./theme/variables.css"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any persistQueryClient({ queryClient: queryClient as any, persister: createIdbPersister(), maxAge: 30 * 60 * 1000 }); const app = createApp(App).use(IonicVue).use(router).use(createPinia()).use(VueQueryPlugin, { queryClient }); diff --git a/src/mocks/repos.ts b/src/mocks/repos.ts index 64de170..4abf168 100644 --- a/src/mocks/repos.ts +++ b/src/mocks/repos.ts @@ -3,6 +3,7 @@ import type { RepoSummary, RepoDetail, RepoFile } from "@/domain/models/repo.js" const MOCK_REPOS: RepoSummary[] = [ { atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/atproto-explorer", + rkey: "atproto-explorer", ownerDid: "did:plc:a1b2c3d4e5f6g7h8i9j0k1l2", ownerHandle: "alice.tngl.sh", name: "atproto-explorer", @@ -15,6 +16,7 @@ const MOCK_REPOS: RepoSummary[] = [ }, { atUri: "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.repo/twisted", + rkey: "twisted", ownerDid: "did:plc:p2cp5gopk7mgjegy9waligxd", ownerHandle: "desertthunder.dev", name: "twisted", @@ -27,6 +29,7 @@ const MOCK_REPOS: RepoSummary[] = [ }, { atUri: "at://did:plc:b2c3d4e5f6g7h8i9j0k1l2m3/sh.tangled.repo/git-log-pretty", + rkey: "git-log-pretty", ownerDid: "did:plc:b2c3d4e5f6g7h8i9j0k1l2m3", ownerHandle: "bob.tngl.sh", name: "git-log-pretty", @@ -39,6 +42,7 @@ const MOCK_REPOS: RepoSummary[] = [ }, { atUri: "at://did:plc:c3d4e5f6g7h8i9j0k1l2m3n4/sh.tangled.repo/iris-ui", + rkey: "iris-ui", ownerDid: "did:plc:c3d4e5f6g7h8i9j0k1l2m3n4", ownerHandle: "clara.bsky.social", name: "iris-ui", @@ -51,6 +55,7 @@ const MOCK_REPOS: RepoSummary[] = [ }, { atUri: "at://did:plc:e5f6g7h8i9j0k1l2m3n4o5p6/sh.tangled.repo/nix-atproto", + rkey: "nix-atproto", ownerDid: "did:plc:e5f6g7h8i9j0k1l2m3n4o5p6", ownerHandle: "riku.tngl.sh", name: "nix-atproto", @@ -63,6 +68,7 @@ const MOCK_REPOS: RepoSummary[] = [ }, { atUri: "at://did:plc:d4e5f6g7h8i9j0k1l2m3n4o5/sh.tangled.repo/tangled-cli", + rkey: "tangled-cli", ownerDid: "did:plc:d4e5f6g7h8i9j0k1l2m3n4o5", ownerHandle: "dev.tangled.sh", name: "tangled-cli", @@ -75,6 +81,7 @@ const MOCK_REPOS: RepoSummary[] = [ }, { atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/lexicon-validator", + rkey: "lexicon-validator", ownerDid: "did:plc:a1b2c3d4e5f6g7h8i9j0k1l2", ownerHandle: "alice.tngl.sh", name: "lexicon-validator", @@ -87,6 +94,7 @@ const MOCK_REPOS: RepoSummary[] = [ }, { atUri: "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.repo/bsky-feeds", + rkey: "bsky-feeds", ownerDid: "did:plc:p2cp5gopk7mgjegy9waligxd", ownerHandle: "desertthunder.dev", name: "bsky-feeds", diff --git a/src/services/tangled/endpoints.ts b/src/services/tangled/endpoints.ts index 69d4a29..f81852d 100644 --- a/src/services/tangled/endpoints.ts +++ b/src/services/tangled/endpoints.ts @@ -1,8 +1,9 @@ /** * Typed wrappers around XRPC queries to Tangled knots and the AT Protocol PDS. * - * All functions accept a Client instance so callers can route to the correct - * knot host (via getKnotClient) or to the PDS (via pdsClient). + * Knot endpoints use raw fetch so we can control query serialization for the + * `repo=did:.../repoName` parameter. PDS endpoints also use raw fetch because + * some `com.atproto.repo.*` calls are not typed in the installed packages. * * --- API Validation Notes (to verify against live endpoints) --- * Knot XRPC base: https:///xrpc/ (e.g. us-west.tangled.sh) @@ -14,11 +15,10 @@ * * Data routing: * - Git data (tree, blob, log, branches, languages) → knot XRPC - * - Repo metadata & profile → PDS com.atproto.repo.getRecord + * - Repo metadata & profile → PDS com.atproto.repo.getRecord/listRecords */ -import type { Client } from "@atcute/client"; -import type { +import { ShTangledRepoTree, ShTangledRepoBlob, ShTangledRepoGetDefaultBranch, @@ -38,42 +38,88 @@ import type { ShTangledString, } from "@atcute/tangled"; import { throwOnXrpcError } from "@/services/atproto/client.js"; -import { MalformedResponseError } from "@/core/errors/tangled.js"; +import { MalformedResponseError, NotFoundError } from "@/core/errors/tangled.js"; + +type KnotParams = Record>; + +function encodeKnotQueryParam(key: string, value: string | number | boolean): string { + const encodedValue = encodeURIComponent(String(value)); + return `${encodeURIComponent(key)}=${key === "repo" ? encodedValue.replaceAll("%2F", "/") : encodedValue}`; +} + +function buildKnotQuery(params: KnotParams): string { + const pairs: string[] = []; + + for (const [key, rawValue] of Object.entries(params)) { + if (rawValue === undefined) continue; + + if (Array.isArray(rawValue)) { + for (const value of rawValue) { + pairs.push(encodeKnotQueryParam(key, value)); + } + continue; + } + + pairs.push(encodeKnotQueryParam(key, rawValue)); + } + + return pairs.length > 0 ? `?${pairs.join("&")}` : ""; +} + +export function buildKnotUrl(knotHost: string, nsid: string, params: KnotParams): string { + return `https://${knotHost}/xrpc/${nsid}${buildKnotQuery(params)}`; +} + +async function readKnotError(res: Response): Promise { + const contentType = res.headers.get("content-type") ?? ""; + + if (contentType.includes("application/json")) { + const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; + throwOnXrpcError(res.status, body.error ?? "Unknown", body.message); + } + + const text = await res.text().catch(() => ""); + throwOnXrpcError(res.status, "Unknown", text || undefined); +} + +async function fetchKnotJson(knotHost: string, nsid: string, params: KnotParams): Promise { + const res = await fetch(buildKnotUrl(knotHost, nsid, params)); + if (!res.ok) return readKnotError(res); + return res.json() as Promise; +} + +async function fetchKnotBytes(knotHost: string, nsid: string, params: KnotParams): Promise { + const res = await fetch(buildKnotUrl(knotHost, nsid, params)); + if (!res.ok) return readKnotError(res); + return new Uint8Array(await res.arrayBuffer()); +} export async function fetchRepoTree( - client: Client, + knotHost: string, params: ShTangledRepoTree.$params, ): Promise { - const res = await client.get("sh.tangled.repo.tree", { params }); - if (!res.ok) throwOnXrpcError(res.status, res.data.error, res.data.message); - return res.data; + return fetchKnotJson(knotHost, "sh.tangled.repo.tree", params); } export async function fetchRepoBlob( - client: Client, + knotHost: string, params: ShTangledRepoBlob.$params, ): Promise { - const res = await client.get("sh.tangled.repo.blob", { params }); - if (!res.ok) throwOnXrpcError(res.status, res.data.error, res.data.message); - return res.data; + return fetchKnotJson(knotHost, "sh.tangled.repo.blob", params); } export async function fetchDefaultBranch( - client: Client, + knotHost: string, params: ShTangledRepoGetDefaultBranch.$params, ): Promise { - const res = await client.get("sh.tangled.repo.getDefaultBranch", { params }); - if (!res.ok) throwOnXrpcError(res.status, res.data.error, res.data.message); - return res.data; + return fetchKnotJson(knotHost, "sh.tangled.repo.getDefaultBranch", params); } export async function fetchLanguages( - client: Client, + knotHost: string, params: ShTangledRepoLanguages.$params, ): Promise { - const res = await client.get("sh.tangled.repo.languages", { params }); - if (!res.ok) throwOnXrpcError(res.status, res.data.error, res.data.message); - return res.data; + return fetchKnotJson(knotHost, "sh.tangled.repo.languages", params); } /** @@ -82,12 +128,10 @@ export async function fetchLanguages( * the live API. Expected: newline-delimited JSON or git log text. */ export async function fetchRepoLog( - client: Client, + knotHost: string, params: { repo: string; ref: string; path?: string; limit?: number; cursor?: string }, ): Promise { - const res = await client.get("sh.tangled.repo.log", { params, as: "bytes" }); - if (!res.ok) throwOnXrpcError(res.status, (res.data as { error: string }).error); - return new TextDecoder().decode(res.data as Uint8Array); + return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.log", params)); } /** @@ -95,33 +139,25 @@ export async function fetchRepoLog( * for the normalizer to parse once the live format is confirmed. */ export async function fetchRepoBranches( - client: Client, + knotHost: string, params: { repo: string; limit?: number; cursor?: string }, ): Promise { - const res = await client.get("sh.tangled.repo.branches", { params, as: "bytes" }); - if (!res.ok) throwOnXrpcError(res.status, (res.data as { error: string }).error); - return new TextDecoder().decode(res.data as Uint8Array); + return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.branches", params)); } /** Tag list. Wire format is a raw blob — decoded text returned for normalizer. */ -export async function fetchRepoTags(client: Client, params: ShTangledRepoTags.$params): Promise { - const res = await client.get("sh.tangled.repo.tags", { params, as: "bytes" }); - if (!res.ok) throwOnXrpcError(res.status, (res.data as { error: string }).error); - return new TextDecoder().decode(res.data as Uint8Array); +export async function fetchRepoTags(knotHost: string, params: ShTangledRepoTags.$params): Promise { + return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.tags", params)); } /** Diff for a ref. Wire format is a raw blob — patch text. */ -export async function fetchRepoDiff(client: Client, params: ShTangledRepoDiff.$params): Promise { - const res = await client.get("sh.tangled.repo.diff", { params, as: "bytes" }); - if (!res.ok) throwOnXrpcError(res.status, (res.data as { error: string }).error); - return new TextDecoder().decode(res.data as Uint8Array); +export async function fetchRepoDiff(knotHost: string, params: ShTangledRepoDiff.$params): Promise { + return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.diff", params)); } /** Comparison between two revisions. Wire format is a raw blob — patch text. */ -export async function fetchRepoCompare(client: Client, params: ShTangledRepoCompare.$params): Promise { - const res = await client.get("sh.tangled.repo.compare", { params, as: "bytes" }); - if (!res.ok) throwOnXrpcError(res.status, (res.data as { error: string }).error); - return new TextDecoder().decode(res.data as Uint8Array); +export async function fetchRepoCompare(knotHost: string, params: ShTangledRepoCompare.$params): Promise { + return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.compare", params)); } type GetRecordResponse = { uri: string; cid: string; value: T }; @@ -157,12 +193,40 @@ export async function fetchActorProfile( return getRecord(pds, did, "sh.tangled.actor.profile", "self"); } +/** + * Fetch a repo record by its PDS record key. + * This is distinct from the repo's `name`, which is the identifier used by + * knot endpoints in the `did:.../repoName` format. + */ export async function fetchRepoRecord( + pds: string, + did: string, + rkey: string, +): Promise> { + return getRecord(pds, did, "sh.tangled.repo", rkey); +} + +/** + * Fetch a repo record by matching on the record's `name` field. + * Use this when the UI route or knot API identifies a repo by repo name rather + * than by the underlying AT Protocol record key. + */ +export async function fetchRepoRecordByName( pds: string, did: string, repoName: string, ): Promise> { - return getRecord(pds, did, "sh.tangled.repo", repoName); + let cursor: string | undefined; + + for (;;) { + const response = await listRepoRecords(pds, did, 100, cursor); + const record = response.records.find((entry) => entry.value.name === repoName); + if (record) return record; + if (!response.cursor) break; + cursor = response.cursor; + } + + throw new NotFoundError(`Repository ${repoName}`); } export async function fetchIssueRecord( diff --git a/src/services/tangled/normalizers.ts b/src/services/tangled/normalizers.ts index 7561339..a715b0f 100644 --- a/src/services/tangled/normalizers.ts +++ b/src/services/tangled/normalizers.ts @@ -27,8 +27,9 @@ import type { StringSummary } from "@/domain/models/string.js"; import { getAtUriRkey } from "./uris.js"; function modeToFileKind(mode: string): RepoFile["type"] { - if (mode.startsWith("04")) return "dir"; - if (mode === "160000") return "submodule"; + const normalizedMode = mode.replace(/^0+/, ""); + if (normalizedMode === "40000") return "dir"; + if (normalizedMode === "160000") return "submodule"; return "file"; } @@ -169,6 +170,7 @@ export function normalizeRepoRecord( ): RepoSummary { return { atUri, + rkey: getAtUriRkey(atUri), ownerDid, ownerHandle, name: record.name, @@ -377,7 +379,6 @@ export function normalizeActorProfile( did, handle, displayName, - avatar: `https://avatar.tangled.sh/${did}`, bio: record.description, location: record.location, pronouns: record.pronouns, diff --git a/src/services/tangled/queries.ts b/src/services/tangled/queries.ts index 046c02b..6ac18bb 100644 --- a/src/services/tangled/queries.ts +++ b/src/services/tangled/queries.ts @@ -16,7 +16,6 @@ import { useQuery } from "@tanstack/vue-query"; import { computed, toValue } from "vue"; import type { MaybeRef } from "vue"; -import { getKnotClient } from "@/services/atproto/client.js"; import type { FollowedUserSummary } from "@/domain/models/follow.js"; import type { StringSummary } from "@/domain/models/string.js"; import { @@ -30,7 +29,7 @@ import { fetchRepoDiff, fetchRepoCompare, fetchActorProfile, - fetchRepoRecord, + fetchRepoRecordByName, fetchIssueRecord, fetchPullRecord, listRepoRecords, @@ -71,6 +70,14 @@ export type { CommitEntry, BranchEntry, BlobContent, DefaultBranchInfo } from ". const MIN = 60_000; +function hasText(value: MaybeRef): boolean { + return !!toValue(value)?.trim(); +} + +function isEnabled(required: boolean, enabled?: MaybeRef): boolean { + return required && (enabled === undefined || !!toValue(enabled)); +} + /** Resolved identity: DID + PDS hostname for an AT Protocol handle. */ export type Identity = { did: string; pds: string }; @@ -79,14 +86,16 @@ export type Identity = { did: string; pds: string }; * Result is cached for 10 minutes (handles rarely change). */ export function useIdentity(handle: MaybeRef, options: { enabled?: MaybeRef } = {}) { + const normalizedHandle = computed(() => toValue(handle).trim()); + return useQuery({ - queryKey: computed(() => ["identity", toValue(handle)]), + queryKey: computed(() => ["identity", normalizedHandle.value]), queryFn: async (): Promise => { - const did = await resolveHandle(toValue(handle)); + const did = await resolveHandle(normalizedHandle.value); const pds = await resolvePds(did); return { did, pds }; }, - enabled: options.enabled, + enabled: computed(() => isEnabled(hasText(normalizedHandle), options.enabled)), staleTime: 10 * MIN, gcTime: 60 * MIN, }); @@ -103,7 +112,7 @@ export function useRepoTree( return useQuery({ queryKey: computed(() => ["tree", toValue(knotHost), toValue(repo), toValue(ref), toValue(path)]), queryFn: () => - fetchRepoTree(getKnotClient(toValue(knotHost)), { + fetchRepoTree(toValue(knotHost), { repo: toValue(repo), ref: toValue(ref), path: toValue(path), @@ -125,7 +134,7 @@ export function useRepoBlob( return useQuery({ queryKey: computed(() => ["blob", toValue(knotHost), toValue(repo), toValue(ref), toValue(path)]), queryFn: () => - fetchRepoBlob(getKnotClient(toValue(knotHost)), { + fetchRepoBlob(toValue(knotHost), { repo: toValue(repo), ref: toValue(ref), path: toValue(path), @@ -145,7 +154,7 @@ export function useDefaultBranch( return useQuery({ queryKey: computed(() => ["defaultBranch", toValue(knotHost), toValue(repo)]), queryFn: () => - fetchDefaultBranch(getKnotClient(toValue(knotHost)), { repo: toValue(repo) }).then(normalizeDefaultBranch), + fetchDefaultBranch(toValue(knotHost), { repo: toValue(repo) }).then(normalizeDefaultBranch), enabled: options.enabled, staleTime: 5 * MIN, gcTime: 30 * MIN, @@ -162,9 +171,7 @@ export function useRepoLanguages( return useQuery({ queryKey: computed(() => ["languages", toValue(knotHost), toValue(repo), toValue(ref)]), queryFn: () => - fetchLanguages(getKnotClient(toValue(knotHost)), { repo: toValue(repo), ref: toValue(ref) }).then( - normalizeLanguages, - ), + fetchLanguages(toValue(knotHost), { repo: toValue(repo), ref: toValue(ref) }).then(normalizeLanguages), enabled: options.enabled, staleTime: 5 * MIN, gcTime: 30 * MIN, @@ -193,7 +200,7 @@ export function useRepoLog( toValue(options.cursor), ]), queryFn: () => - fetchRepoLog(getKnotClient(toValue(knotHost)), { + fetchRepoLog(toValue(knotHost), { repo: toValue(repo), ref: toValue(ref), path: toValue(options.path), @@ -216,7 +223,7 @@ export function useRepoBranches( return useQuery({ queryKey: computed(() => ["branches", toValue(knotHost), toValue(repo)]), queryFn: () => - fetchRepoBranches(getKnotClient(toValue(knotHost)), { repo: toValue(repo) }).then((raw) => + fetchRepoBranches(toValue(knotHost), { repo: toValue(repo) }).then((raw) => normalizeBranchesText(raw, toValue(defaultBranch)), ), enabled: options.enabled, @@ -236,16 +243,26 @@ export function useRepoRecord( handle: MaybeRef, options: { enabled?: MaybeRef } = {}, ) { + const normalizedPds = computed(() => toValue(pds).trim()); + const normalizedDid = computed(() => toValue(did).trim()); + const normalizedRepoName = computed(() => toValue(repoName).trim()); + return useQuery({ - queryKey: computed(() => ["repoRecord", toValue(pds), toValue(did), toValue(repoName)]), + queryKey: computed(() => ["repoRecord", normalizedPds.value, normalizedDid.value, normalizedRepoName.value]), queryFn: async () => { - const { value: record, uri } = await fetchRepoRecord(toValue(pds), toValue(did), toValue(repoName)).then((r) => ({ + const { value: record, uri } = await fetchRepoRecordByName( + normalizedPds.value, + normalizedDid.value, + normalizedRepoName.value, + ).then((r) => ({ value: r.value, uri: r.uri, })); return normalizeRepoRecord(record, toValue(did), toValue(handle), uri); }, - enabled: options.enabled, + enabled: computed(() => + isEnabled(hasText(normalizedPds) && hasText(normalizedDid) && hasText(normalizedRepoName), options.enabled), + ), staleTime: 5 * MIN, gcTime: 30 * MIN, }); @@ -258,13 +275,16 @@ export function useUserRepos( handle: MaybeRef, options: { enabled?: MaybeRef } = {}, ) { + const normalizedPds = computed(() => toValue(pds).trim()); + const normalizedDid = computed(() => toValue(did).trim()); + return useQuery({ - queryKey: computed(() => ["userRepos", toValue(pds), toValue(did)]), + queryKey: computed(() => ["userRepos", normalizedPds.value, normalizedDid.value]), queryFn: async () => { - const { records } = await listRepoRecords(toValue(pds), toValue(did)); + const { records } = await listRepoRecords(normalizedPds.value, normalizedDid.value); return records.map((r) => normalizeRepoRecord(r.value, toValue(did), toValue(handle), r.uri)); }, - enabled: options.enabled, + enabled: computed(() => isEnabled(hasText(normalizedPds) && hasText(normalizedDid), options.enabled)), staleTime: 5 * MIN, gcTime: 30 * MIN, }); @@ -278,13 +298,16 @@ export function useActorProfile( displayName?: MaybeRef, options: { enabled?: MaybeRef } = {}, ) { + const normalizedPds = computed(() => toValue(pds).trim()); + const normalizedDid = computed(() => toValue(did).trim()); + return useQuery({ - queryKey: computed(() => ["actorProfile", toValue(pds), toValue(did)]), + queryKey: computed(() => ["actorProfile", normalizedPds.value, normalizedDid.value]), queryFn: async () => { - const { value } = await fetchActorProfile(toValue(pds), toValue(did)); + const { value } = await fetchActorProfile(normalizedPds.value, normalizedDid.value); return normalizeActorProfile(value, toValue(did), toValue(handle), toValue(displayName)); }, - enabled: options.enabled, + enabled: computed(() => isEnabled(hasText(normalizedPds) && hasText(normalizedDid), options.enabled)), staleTime: 10 * MIN, gcTime: 60 * MIN, }); @@ -299,9 +322,7 @@ export function useRepoTags( return useQuery({ queryKey: computed(() => ["tags", toValue(knotHost), toValue(repo)]), queryFn: () => - fetchRepoTags(getKnotClient(toValue(knotHost)), { repo: toValue(repo) }).then((raw) => - raw.trim().split("\n").filter(Boolean), - ), + fetchRepoTags(toValue(knotHost), { repo: toValue(repo) }).then((raw) => raw.trim().split("\n").filter(Boolean)), enabled: options.enabled, staleTime: 2 * MIN, gcTime: 10 * MIN, @@ -317,7 +338,7 @@ export function useRepoDiff( ) { return useQuery({ queryKey: computed(() => ["diff", toValue(knotHost), toValue(repo), toValue(ref)]), - queryFn: () => fetchRepoDiff(getKnotClient(toValue(knotHost)), { repo: toValue(repo), ref: toValue(ref) }), + queryFn: () => fetchRepoDiff(toValue(knotHost), { repo: toValue(repo), ref: toValue(ref) }), enabled: options.enabled, staleTime: 5 * MIN, gcTime: 30 * MIN, @@ -335,7 +356,7 @@ export function useRepoCompare( return useQuery({ queryKey: computed(() => ["compare", toValue(knotHost), toValue(repo), toValue(rev1), toValue(rev2)]), queryFn: () => - fetchRepoCompare(getKnotClient(toValue(knotHost)), { + fetchRepoCompare(toValue(knotHost), { repo: toValue(repo), rev1: toValue(rev1), rev2: toValue(rev2), @@ -471,7 +492,6 @@ export function useUserFollowing( return { did: subject.did, handle: subject.handle, - avatar: `https://avatar.tangled.sh/${subject.did}`, followAtUri: follow.atUri, followedAt: follow.createdAt, }; diff --git a/tests/unit/tangled-normalizers.spec.ts b/tests/unit/tangled-normalizers.spec.ts index b655edf..18df9a5 100644 --- a/tests/unit/tangled-normalizers.spec.ts +++ b/tests/unit/tangled-normalizers.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { buildIssueCommentThread } from "@/services/tangled/normalizers.js"; +import { buildKnotUrl } from "@/services/tangled/endpoints.js"; +import { buildIssueCommentThread, normalizeRepoRecord, normalizeTree } from "@/services/tangled/normalizers.js"; import { getAtUriRkey, parseAtUri } from "@/services/tangled/uris.js"; import type { IssueComment } from "@/domain/models/comment.js"; @@ -24,6 +25,50 @@ describe("AT URI helpers", () => { expect(parseAtUri(uri)).toEqual({ did: "did:plc:abc123", collection: "sh.tangled.repo.issue", rkey: "42" }); expect(getAtUriRkey(uri)).toBe("42"); }); + + it("preserves the repo record rkey separately from the display name", () => { + const repo = normalizeRepoRecord( + { + $type: "sh.tangled.repo", + name: "Writer", + knot: "us-west.host.bsky.network", + createdAt: "2026-03-22T10:00:00Z", + }, + "did:plc:abc123", + "alice.test", + "at://did:plc:abc123/sh.tangled.repo/writer-app", + ); + + expect(repo.name).toBe("Writer"); + expect(repo.rkey).toBe("writer-app"); + }); + + it("preserves the repo slash in knot XRPC query strings", () => { + const url = buildKnotUrl("knot1.tangled.sh", "sh.tangled.repo.getDefaultBranch", { + repo: "did:plc:xg2vq45muivyy3xwatcehspu/writer", + }); + + expect(url).toContain("repo=did%3Aplc%3Axg2vq45muivyy3xwatcehspu/writer"); + expect(url).not.toContain("%2Fwriter"); + }); + + it("derives file kinds from zero-padded git modes", () => { + const files = normalizeTree({ + files: [ + { mode: "0040000", name: ".github", size: 75, last_commit: { hash: "a", message: "dir", when: "2026-03-23T00:00:00Z" } }, + { mode: "0100644", name: "README.md", size: 3126, last_commit: { hash: "b", message: "file", when: "2026-03-23T00:00:00Z" } }, + { mode: "0160000", name: "vendor/lib", size: 0, last_commit: { hash: "c", message: "submodule", when: "2026-03-23T00:00:00Z" } }, + ], + lastCommit: { hash: "a", message: "dir", when: "2026-03-23T00:00:00Z", author: { name: "Test", email: "test@example.com", when: "" } }, + ref: "main", + }); + + expect(files.map((file) => [file.name, file.type])).toEqual([ + [".github", "dir"], + ["README.md", "file"], + ["vendor/lib", "submodule"], + ]); + }); }); describe("buildIssueCommentThread", () => { diff --git a/tsconfig.node.json b/tsconfig.node.json index 05e139c..196d8a9 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,11 +1,14 @@ { "compilerOptions": { "composite": true, + "target": "ESNext", + "lib": ["ESNext"], "module": "nodenext", "moduleResolution": "nodenext", "allowSyntheticDefaultImports": true, + "esModuleInterop": true, "skipLibCheck": true, - "types": ["@atcute/bluesky", "@atcute/tangled"] + "types": ["node", "vitest"] }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "cypress.config.ts", "capacitor.config.ts"] }