csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

refactor big

dawn (May 19, 2026, 12:46 AM +0300) bd468bb3 fddafad6

+2381 -2007
+107 -77
AGENTS.md
··· 1 1 # Agent Notes 2 2 3 + ## RTK Commands 4 + 5 + When running shell commands, prefix them with `rtk` by default. If `rtk` has no 6 + filter for a command, it passes through unchanged. 7 + 8 + Examples: 9 + 10 + ```bash 11 + rtk git status 12 + rtk git diff 13 + rtk rg "pattern" src 14 + rtk npm run build 15 + ``` 16 + 17 + Rules: 18 + 19 + - In command chains, prefix each segment: `rtk git add . && rtk git commit -m "msg"`. 20 + - Use `rtk rg` for searching, including TSX/JSX files. 21 + - Do not use `rtk sed`, `rtk proxy sed`, `rtk cat`, `rtk read`, or other RTK-filtered readers for `.tsx` or `.jsx` files; use raw `sed`, `cat`, or similar so JSX syntax is preserved. 22 + - For debugging, use a raw command without `rtk` if the filter hides needed detail. 23 + - Use `apply_patch` for manual edits. 24 + 3 25 ## Build 4 26 5 27 Use this as the main verification command: ··· 8 30 rtk npm run build 9 31 ``` 10 32 11 - The app is a Solid/Vite TypeScript frontend. The build runs `tsc -b && vite build`. 12 - 13 - Do not start the Vite dev server from agent sessions. Use the build command for verification. 33 + This is a Solid/Vite TypeScript frontend. The build runs `tsc -b && vite build`. 34 + Do not start the Vite dev server from agent sessions; use the build command for 35 + verification. 14 36 15 37 ## Architecture Map 16 38 17 - Do not start by reading every file. Start from the area you are changing: 39 + Do not start by reading every file. Start from the area you are changing. 18 40 19 - - `src/App.tsx`: app shell, routing, topbar, footer, home page, OAuth callback, not-found page. 41 + - `src/App.tsx`: provider wiring only. Keep this small. It should compose app-wide providers and render `AppRoutes`. 42 + - `src/routes.tsx`: the declarative route table. Repo routes are nested under `/:owner/:repo` and use `RepoLayout`. 43 + - `src/layout.tsx`: global shell and topbar. Preserve the `.untangled-shell` wrapper so app pages keep the intended width and padding. There is intentionally no footer. 44 + - `src/views/home.tsx`: home page and repository jump/recent/own-repo UI. 45 + - `src/views/oauth-callback.tsx`: OAuth callback completion page. 46 + - `src/views/not-found.tsx`: not-found page. 20 47 - `src/pages/repo.tsx`: barrel exports for repo routes only. 21 - - `src/pages/repo/shared.tsx`: shared repo frame/header/tabs, repo query hook, repo query keys. 48 + - `src/pages/repo/shared.tsx`: repo layout/context, shared repo frame/header/tabs, repo query keys, and `useRepoQuery`. 22 49 - `src/pages/repo/code.tsx`: repo overview/tree page and blob/file viewer. 23 - - `src/pages/repo/issues.tsx`: issue list, new issue, issue detail. 24 - - `src/pages/repo/pulls.tsx`: pull list, new pull, pull detail, patch/diff rendering calls. 25 - - `src/components/common.tsx`: generic UI primitives such as `Avatar`, `LoadingState`, `StateBadge`, form/button/card style helpers. 26 - - `src/components/repo.tsx`: repo presentation components such as tabs, file rows, README/comment cards, `CodeView`, `DiffView`, branch pills. 27 - - `src/lib/api.ts`: API/client calls and response types. 50 + - `src/pages/repo/code-helpers.ts`: pure code-route helpers such as ref/path resolution, language normalization, delayed loading, and navigation click checks. 51 + - `src/pages/repo/issues.tsx`: issue list, new issue, and issue detail. 52 + - `src/pages/repo/issues-helpers.ts`: issue-specific pure helpers such as comment thread building. 53 + - `src/pages/repo/pulls.tsx`: pull list, new pull, pull detail, and patch/diff rendering calls. 54 + - `src/pages/repo/pulls-helpers.ts`: pull-specific pure helpers such as state filter parsing. 55 + - `src/components/common.tsx`: generic UI primitives such as `Avatar`, `LoadingState`, `StateBadge`, and form/button/card style helpers. 56 + - `src/components/repo.tsx`: repo presentation components such as tabs, file rows, README/comment cards, `CodeView`, `DiffView`, branch pills, and repo skeletons. 57 + - `src/lib/api.ts`: compatibility facade that re-exports domain APIs. Do not add implementation here. 58 + - `src/lib/api/core.ts`: current low-level API implementation and shared private helpers. 59 + - `src/lib/api/constants.ts`: public service URLs and OAuth scope exports. 60 + - `src/lib/api/identity.ts`: auth RPC, identity resolver, actor resolution, avatar resolution. 61 + - `src/lib/api/repos.ts`: repo records, repo metadata, tree/blob/branch/tag/log/language APIs, compare APIs, and related response types. 62 + - `src/lib/api/issues.ts`: issue list/detail/create/comment/state APIs and issue types. 63 + - `src/lib/api/pulls.ts`: pull list/detail/create/comment/status/patch APIs and pull types. 64 + - `src/lib/api/stars.ts`: repo star summary/count/create/delete APIs and star types. 65 + - `src/lib/api/records.ts`: shared record utilities such as AT URI parsing and blob CID extraction. 28 66 - `src/lib/auth.tsx`: auth provider and OAuth session state. 29 67 - `src/lib/live-events.tsx`: live websocket event provider/hook. 30 - - `src/lib/repo-utils.ts`: repo URL/path helpers, file type checks, formatting, recent repo storage, language colors, tree sorting. 68 + - `src/lib/repo-utils.ts`: repo URL/path helpers, file type checks, formatting, recent repo storage, language colors, tree sorting, and shared pure repo helpers. 31 69 - `src/index.css`: global CSS plus explicit utility classes used to replace missing Tailwind arbitrary classes. 32 70 71 + ## Repo Route Pattern 72 + 73 + Repo pages live under `RepoLayout` in `src/pages/repo/shared.tsx`. 74 + 75 + - `RepoLayout` creates the shared repo query once for the `/:owner/:repo` route subtree. 76 + - Child routes must call `useRepoQuery()` instead of creating their own repo query. 77 + - `RepoFrame` owns the shared repo chrome, tab links, star control, recent-repo storage, and live event subscription. 78 + - New repo-route behavior should usually go in a focused `src/pages/repo/*.tsx` file or a small helper beside it, not in `src/App.tsx` or `src/routes.tsx`. 79 + - Keep route files from growing indefinitely. Move pure helpers into `*-helpers.ts` files and reusable display pieces into `src/components/repo.tsx`. 80 + 81 + ## API Module Pattern 82 + 83 + Use domain imports for new code: 84 + 85 + ```ts 86 + import { getRepoTree } from '../../lib/api/repos'; 87 + import { createIssue } from '../../lib/api/issues'; 88 + ``` 89 + 90 + The facade `src/lib/api.ts` remains for compatibility with existing imports, but new implementation should go into the relevant domain module or, if it truly must share private internals, into `src/lib/api/core.ts` and be re-exported through the domain module. 91 + 92 + Guidelines: 93 + 94 + - Do not add new implementation to `src/lib/api.ts`. 95 + - Prefer keeping public types next to the domain module that exposes the API. 96 + - Keep ATProto/PDS resolution rules centralized; repo UI record hydration should resolve the actor/PDS first and call the record endpoint against that PDS. 97 + - Do not use `public.api.bsky.app` for repo `listRecords` or record hydration. 98 + 33 99 ## Styling Source Of Truth 34 100 35 101 This frontend is intended to match the upstream Tangled app templates in: ··· 50 116 - `templates/repo/pulls/fragments/*` 51 117 - `static/tw.css` 52 118 53 - When matching visuals, compare against those templates before inventing new layout/styling. 119 + When matching visuals, compare against those templates before inventing new 120 + layout/styling. 54 121 55 122 ## Upstream Comparison Workflow 56 123 57 - For Tangled parity work, use upstream as the contract and compare details before changing local components: 124 + For Tangled parity work, use upstream as the contract and compare details before 125 + changing local components: 58 126 59 127 - Start from the matching upstream template in `../tangled-upstream/appview/pages/templates/...`, then check nearby Go/router files when behavior or icon names are data-driven. 60 128 - For repo chrome, `templates/layouts/repobase.html` is the main reference. Confirm header grouping, action buttons, tab icons, label spacing, font sizes, and vertical padding against that file before patching `src/pages/repo/shared.tsx`. 61 129 - For code/blob views, compare `templates/repo/index.html` and `templates/repo/blob.html` before changing `src/pages/repo/code.tsx`, `src/components/repo.tsx`, or file-view CSS. 62 - - Treat screenshots as prompts to inspect upstream source, not as the only source of truth. Small differences such as `gap-*`, `py-*`, icon choice, and short-rev styling matter. 63 - - Remove or hide upstream controls that are not implemented locally instead of shipping fake UI. Recent examples: fork action and topbar repo search/jump. 130 + - Treat screenshots as prompts to inspect upstream source, not as the only source of truth. 131 + - Small differences such as `gap-*`, `py-*`, icon choice, and short-rev styling matter. 132 + - Remove or hide upstream controls that are not implemented locally instead of shipping fake UI. 64 133 - External upstream-only affordances can link to `https://tangled.org`, such as repo stars and `/:owner/:repo/feed.atom`. 65 - - When fetching ATProto records for repo UI, resolve the actor/PDS first and call the record endpoint against that PDS. Do not use `public.api.bsky.app` for repo `listRecords`/record hydration. 66 - - If Vite HMR reports a removed handler or prop after a UI deletion, confirm with `rtk grep` and `rtk npm run build`; stale dev-server modules can survive until the server or page is restarted. 67 134 68 135 ## Tailwind/CSS Pitfall 69 136 70 - The checked-in `public/static/tw.css` does not include all arbitrary Tailwind classes. Avoid relying on classes such as: 137 + The checked-in `public/static/tw.css` does not include all arbitrary Tailwind 138 + classes. Avoid relying on classes such as: 71 139 72 140 - `grid-cols-[...]` 73 141 - `[overflow-wrap:anywhere]` 74 142 - `dark:[&_code]:...` 75 143 - arbitrary safe-area or min-height utilities 76 144 77 - If a class is not present in `public/static/tw.css`, add an explicit `.untangled-*` rule in `src/index.css` instead. Many previous visual bugs were caused by the browser dropping missing arbitrary classes. 145 + If a class is not present in `public/static/tw.css`, add an explicit 146 + `.untangled-*` rule in `src/index.css` instead. 78 147 79 148 ## Repo UI Notes 80 149 150 + - Preserve the `.untangled-shell` wrapper in `RootShell`; widening pages changes the app layout. 151 + - Do not add a footer unless explicitly requested. 81 152 - File/tree ordering should be folders first, then files alphabetically. This is implemented in `sortedTreeEntries` in `src/lib/repo-utils.ts`. 82 153 - File/blob rendering uses `CodeView` in `src/components/repo.tsx`. 83 154 - PR diffs use `DiffView`, which parses unified patches into collapsible per-file panels and reuses `CodeView`. 84 155 - Repo overview and blob pages should resolve `HEAD` to the default branch for user-facing links when possible. 85 156 - Keep unimplemented upstream features visually inert rather than adding fake behavior. 86 - 87 157 - Repo commit lists should show author avatars like upstream. Upstream maps verified commit author emails to DIDs server-side; the local app can render `Avatar` when the commit author email is already a DID, otherwise use `PlaceholderAvatar`. 88 158 - The upstream placeholder avatar is a rounded gray background with a centered `user-round` icon. Use `PlaceholderAvatar` from `src/components/common.tsx` instead of a plain empty circle. 89 159 - Do not show a verified-looking commit badge unless signature validity is actually checked. Use the neutral commit badge/icon for commit hashes. 90 160 - Repo stars must count backlinks for both `sh.tangled.feed.star:subject.did` and legacy/alternate `sh.tangled.feed.star:subjectDid`, then dedupe hydrated refs. 91 161 - Star and unstar should be optimistic. Unstar deletes the current user's `sh.tangled.feed.star` record and should update local state without refetching. 92 162 - While star summary is loading, make the star control unclickable and show the upstream-style `loader-circle` spinner (`LoaderCircle` with `animate-spin` locally). 93 - - Markdown rendering should not enable hard line breaks globally for README-style content; `marked` should use `breaks: false` to avoid mid-line wrapping artifacts. 163 + - Markdown rendering should not enable hard line breaks globally for README-style content; `marked` should use `breaks: false`. 94 164 95 165 ## Editing Guidance 96 166 97 - - Keep `src/App.tsx` small. New repo-route behavior should usually go into `src/pages/repo/*.tsx`. 167 + - Keep `src/App.tsx` small. 168 + - Keep route declarations in `src/routes.tsx`. 169 + - Keep global shell/topbar work in `src/layout.tsx`. 170 + - Keep top-level non-repo pages in `src/views`. 171 + - Keep repo-route behavior in `src/pages/repo/*.tsx`. 172 + - Keep repo-route pure helpers in nearby `src/pages/repo/*-helpers.ts` files. 98 173 - Keep generic display components in `src/components/common.tsx`. 99 174 - Keep repo-specific display components in `src/components/repo.tsx`. 100 - - Keep pure helpers in `src/lib/repo-utils.ts`. 175 + - Keep shared pure repo helpers in `src/lib/repo-utils.ts`. 101 176 - Do not add large page logic back into `src/App.tsx`. 102 - - Use `apply_patch` for manual edits. 103 - - Do not remove existing behavior while restructuring. Run `rtk npm run build` after meaningful changes. 177 + - Do not remove existing behavior while restructuring. 178 + - Run `rtk npm run build` after meaningful changes. 104 179 105 180 ## Current Size Guide 106 181 107 - Approximate current file sizes: 182 + Approximate current file roles: 108 183 109 - - `src/App.tsx`: small app shell and routing. 110 - - `src/pages/repo/code.tsx`: largest file; split further if code/blob logic grows. 184 + - `src/App.tsx`: tiny provider shell. 185 + - `src/layout.tsx`: global topbar/shell. 186 + - `src/routes.tsx`: route table. 187 + - `src/pages/repo/code.tsx`: largest repo route; split further if code/blob logic grows. 111 188 - `src/pages/repo/pulls.tsx`: pull routes and patch/diff UI. 112 189 - `src/pages/repo/issues.tsx`: issue routes. 113 - 114 - If a file grows substantially, prefer extracting focused components or helpers rather than continuing to grow the route file. 115 - 116 - 117 - <!-- headroom:rtk-instructions --> 118 - # RTK (Rust Token Killer) - Token-Optimized Commands 119 - 120 - When running shell commands, **always prefix with `rtk`**. This reduces context 121 - usage by 60-90% with zero behavior change. If rtk has no filter for a command, 122 - it passes through unchanged — so it is always safe to use. 123 - 124 - ## Key Commands 125 - ```bash 126 - # Git (59-80% savings) 127 - rtk git status rtk git diff rtk git log 128 - 129 - # Files & Search (60-75% savings) 130 - rtk ls <path> rtk read <file> rtk grep <pattern> 131 - rtk find <pattern> rtk diff <file> 132 - 133 - # Test (90-99% savings) — shows failures only 134 - rtk pytest tests/ rtk cargo test rtk test <cmd> 135 - 136 - # Build & Lint (80-90% savings) — shows errors only 137 - rtk tsc rtk lint rtk cargo build 138 - rtk prettier --check rtk mypy rtk ruff check 139 - 140 - # Analysis (70-90% savings) 141 - rtk err <cmd> rtk log <file> rtk json <file> 142 - rtk summary <cmd> rtk deps rtk env 143 - 144 - # GitHub (26-87% savings) 145 - rtk gh pr view <n> rtk gh run list rtk gh issue list 146 - 147 - # Infrastructure (85% savings) 148 - rtk docker ps rtk kubectl get rtk docker logs <c> 149 - 150 - # Package managers (70-90% savings) 151 - rtk pip list rtk pnpm install rtk npm run <script> 152 - ``` 153 - 154 - ## Rules 155 - - In command chains, prefix each segment: `rtk git add . && rtk git commit -m "msg"` 156 - - Use `rtk rg` for searching, including TSX/JSX files. 157 - - Do not use RTK for reading `.tsx` or `.jsx` files; use raw `sed`, `cat`, or similar so JSX syntax is preserved. 158 - - For debugging, use raw command without rtk prefix. 159 - - `rtk proxy <cmd>` runs command without filtering but tracks usage 160 - <!-- /headroom:rtk-instructions --> 190 + - `src/lib/api/core.ts`: compatibility implementation backend; prefer new domain modules for new API surfaces.
+7 -346
src/App.tsx
··· 1 - import clsx from 'clsx'; 2 - import { Bell, LogOut } from 'lucide-solid'; 3 - import { A, Route, Router, useNavigate } from '@solidjs/router'; 4 - import { QueryClient, QueryClientProvider, createQuery } from '@tanstack/solid-query'; 5 - import { For, Show, createMemo, createSignal, onMount, type Component, type JSX } from 'solid-js'; 6 - import { listRepoRecords, resolveActor } from './lib/api'; 7 - import { AuthProvider, useAuth } from './lib/auth'; 8 - import { Avatar, LoadingState, buttonStyles, cardStyles, inputStyles } from './components/common'; 9 - import { formatRelativeTime, loadRecentRepos, parseRepoJump } from './lib/repo-utils'; 10 - import { LiveEventsProvider, useLiveEvents, type LiveEvent } from './lib/live-events'; 11 - import { 12 - BlobPage, 13 - IssuePage, 14 - IssuesPage, 15 - NewIssuePage, 16 - NewPullPage, 17 - PullPage, 18 - PullsPage, 19 - RepoCodePage, 20 - } from './pages/repo'; 1 + import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'; 2 + import type { Component } from 'solid-js'; 3 + 4 + import { AuthProvider } from './lib/auth'; 5 + import { LiveEventsProvider } from './lib/live-events'; 6 + import { AppRoutes } from './routes'; 21 7 22 8 const queryClient = new QueryClient({ 23 9 defaultOptions: { ··· 27 13 }, 28 14 }, 29 15 }); 30 - 31 - 32 - const SAMPLE_REPOS = [ 33 - { owner: 'mary.my.id', slug: 'atcute' }, 34 - { owner: 'mary.my.id', slug: 'boat' }, 35 - { owner: 'mary.my.id', slug: 'aglais' }, 36 - ]; 37 - 38 16 39 17 const App: Component = () => ( 40 18 <QueryClientProvider client={queryClient}> 41 19 <AuthProvider> 42 20 <LiveEventsProvider> 43 - <Router root={RootShell}> 44 - <Route path="/" component={HomePage} /> 45 - <Route path="/oauth/callback" component={OAuthCallbackPage} /> 46 - <Route path="/:owner/:repo/issues/new" component={NewIssuePage} /> 47 - <Route path="/:owner/:repo/issues/:issueRef" component={IssuePage} /> 48 - <Route path="/:owner/:repo/issues" component={IssuesPage} /> 49 - <Route path="/:owner/:repo/pulls/new" component={NewPullPage} /> 50 - <Route path="/:owner/:repo/pulls/:pullRef" component={PullPage} /> 51 - <Route path="/:owner/:repo/pulls" component={PullsPage} /> 52 - <Route path="/:owner/:repo/blob/:ref/*path" component={BlobPage} /> 53 - <Route path="/:owner/:repo/tree/:ref/*path" component={RepoCodePage} /> 54 - <Route path="/:owner/:repo" component={RepoCodePage} /> 55 - <Route path="*404" component={NotFoundPage} /> 56 - </Router> 21 + <AppRoutes /> 57 22 </LiveEventsProvider> 58 23 </AuthProvider> 59 24 </QueryClientProvider> 60 - ); 61 - 62 - const RootShell: Component<{ children?: JSX.Element }> = (props) => ( 63 - <div class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white"> 64 - <header class="untangled-safe-header w-full shadow-sm dark:text-white bg-white dark:bg-gray-800" style="z-index: 20;"> 65 - <Topbar /> 66 - </header> 67 - 68 - <div class="flex-grow"> 69 - <div class="untangled-shell mx-auto flex flex-col gap-4">{props.children}</div> 70 - </div> 71 - </div> 72 - ); 73 - 74 - export const Footer: Component = () => { 75 - const linkStyle = 'hover:text-gray-900 hover:underline dark:hover:text-gray-100'; 76 - 77 - return ( 78 - <div class="w-full border-t border-gray-200 bg-white px-4 py-3 text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400"> 79 - <div class="untangled-footer-links untangled-shell mx-auto"> 80 - <img src="/static/logos/dolly.svg" alt="" class="size-5" /> 81 - <span>&copy; 2026 Tangled Labs Oy.</span> 82 - <a href="https://blog.tangled.org" class={linkStyle}>blog</a> 83 - <a href="https://docs.tangled.org" class={linkStyle}>docs</a> 84 - <a href="https://tangled.org/@tangled.org/core" class={linkStyle}>source</a> 85 - <a href="https://tangled.org/brand" class={linkStyle}>brand</a> 86 - <a href="https://chat.tangled.org" class={linkStyle}>discord</a> 87 - <a href="https://bsky.app/profile/tangled.org" class={linkStyle}>bluesky</a> 88 - <a href="https://x.com/tangled_org" class={linkStyle}>twitter (x)</a> 89 - <a href="/terms" class={linkStyle}>terms</a> 90 - <a href="/privacy" class={linkStyle}>privacy</a> 91 - </div> 92 - </div> 93 - ); 94 - }; 95 - 96 - 97 - const eventLabel = (event: LiveEvent): string => { 98 - if ( 99 - event.source === 'sh.tangled.repo.issue:repoDid' || 100 - event.source === 'sh.tangled.repo.issue:repo' 101 - ) { 102 - return `new issue on ${event.subject}`; 103 - } 104 - if (event.source === 'sh.tangled.repo.pull:target.repo') { 105 - return `new pull on ${event.subject}`; 106 - } 107 - return `${event.source} → ${event.subject}`; 108 - }; 109 - 110 - const Topbar: Component = () => { 111 - const auth = useAuth(); 112 - const live = useLiveEvents(); 113 - const [identifier, setIdentifier] = createSignal(''); 114 - 115 - const viewerQuery = createQuery(() => ({ 116 - queryKey: ['viewer', auth.currentDid()], 117 - enabled: Boolean(auth.currentDid()), 118 - queryFn: async () => resolveActor(auth.currentDid()!), 119 - })); 120 - 121 - const onSignIn = async (event: SubmitEvent) => { 122 - event.preventDefault(); 123 - await auth.signIn(identifier()); 124 - }; 125 - 126 - return ( 127 - <nav class="mx-auto space-x-4 px-6 py-2"> 128 - <div class="flex justify-between p-0 items-center"> 129 - <A href="/" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 130 - <img src="/static/logos/dolly.svg" alt="" class="size-8" /> 131 - <span class="font-bold text-xl">untangled</span> 132 - <span class="font-normal text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">alpha</span> 133 - </A> 134 - 135 - <div class="flex items-center gap-4"> 136 - <Show 137 - when={auth.currentDid()} 138 - fallback={ 139 - <details class="relative"> 140 - <summary class={clsx(buttonStyles(), 'cursor-pointer list-none')}>login</summary> 141 - <div class="absolute right-0 mt-3 w-72 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-lg z-50"> 142 - <form onSubmit={onSignIn} class="flex flex-col gap-3"> 143 - <input 144 - value={identifier()} 145 - onInput={(event) => setIdentifier(event.currentTarget.value)} 146 - placeholder="handle or did" 147 - class={inputStyles()} 148 - /> 149 - <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')}> 150 - continue with OAuth 151 - </button> 152 - <Show when={auth.error()}> 153 - <p class="text-sm text-red-500">{auth.error()}</p> 154 - </Show> 155 - </form> 156 - </div> 157 - </details> 158 - } 159 - > 160 - <details class="relative"> 161 - <summary 162 - class="cursor-pointer list-none flex items-center gap-2" 163 - onClick={() => live.markRead()} 164 - > 165 - <div class="relative"> 166 - <Bell class="size-5 text-gray-500 dark:text-gray-400" /> 167 - <Show when={live.unread() > 0}> 168 - <span class="untangled-unread-badge absolute -top-2 -right-2 min-w-5 h-5 rounded-full bg-blue-600 text-white flex items-center justify-center px-1"> 169 - {Math.min(live.unread(), 99)} 170 - </span> 171 - </Show> 172 - </div> 173 - </summary> 174 - <div class="absolute right-0 mt-3 w-80 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg z-50 overflow-hidden"> 175 - <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 font-medium">live activity</div> 176 - <div class="max-h-96 overflow-auto"> 177 - <Show 178 - when={live.events().length > 0} 179 - fallback={<div class="px-4 py-6 text-sm text-gray-500 dark:text-gray-400">No live events yet.</div>} 180 - > 181 - <For each={live.events()}> 182 - {(event) => ( 183 - <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 text-sm"> 184 - <div class="font-medium break-all">{eventLabel(event)}</div> 185 - <div class="text-gray-500 dark:text-gray-400">{formatRelativeTime(event.receivedAt)}</div> 186 - </div> 187 - )} 188 - </For> 189 - </Show> 190 - </div> 191 - </div> 192 - </details> 193 - 194 - <details class="relative"> 195 - <summary class="cursor-pointer list-none flex items-center gap-2"> 196 - <Avatar did={auth.currentDid()!} size="size-8 md:size-6" /> 197 - <span class="hidden md:inline">{viewerQuery.data?.handle ?? auth.currentDid()}</span> 198 - </summary> 199 - <div class="absolute right-0 mt-4 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50 text-sm w-56"> 200 - <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> 201 - <div class="font-medium truncate">{viewerQuery.data?.handle ?? auth.currentDid()}</div> 202 - <div class="text-gray-500 dark:text-gray-400 truncate">{auth.currentDid()}</div> 203 - </div> 204 - <button 205 - type="button" 206 - class="w-full px-4 py-3 flex items-center gap-2 text-left hover:bg-gray-50 hover:dark:bg-gray-700/50" 207 - onClick={() => void auth.signOut()} 208 - > 209 - <LogOut class="size-4" /> 210 - logout 211 - </button> 212 - </div> 213 - </details> 214 - </Show> 215 - </div> 216 - </div> 217 - </nav> 218 - ); 219 - }; 220 - 221 - const HomePage: Component = () => { 222 - const auth = useAuth(); 223 - const [jump, setJump] = createSignal(''); 224 - const recentRepos = createMemo(loadRecentRepos); 225 - const navigate = useNavigate(); 226 - 227 - const ownReposQuery = createQuery(() => ({ 228 - queryKey: ['own-repos', auth.currentDid()], 229 - enabled: Boolean(auth.currentDid()), 230 - queryFn: async () => { 231 - const actor = await resolveActor(auth.currentDid()!); 232 - return listRepoRecords(actor); 233 - }, 234 - })); 235 - 236 - const onSubmit = (event: SubmitEvent) => { 237 - event.preventDefault(); 238 - const parsed = parseRepoJump(jump()); 239 - if (!parsed) { 240 - return; 241 - } 242 - 243 - navigate(`/${parsed.owner}/${parsed.repo}`); 244 - }; 245 - 246 - return ( 247 - <section class="untangled-home-grid"> 248 - <div class={cardStyles('p-6')}> 249 - <div class="flex items-center gap-3 mb-6"> 250 - <img src="/static/logos/dolly.svg" alt="" class="size-10" /> 251 - <div> 252 - <h1 class="text-2xl font-bold m-0">untangled</h1> 253 - <p class="m-0 text-sm text-gray-500 dark:text-gray-400">tightly-knit social coding</p> 254 - </div> 255 - </div> 256 - 257 - <form onSubmit={onSubmit} class="flex flex-col gap-3"> 258 - <label class="text-sm font-medium">repository</label> 259 - <div class="flex gap-2"> 260 - <input 261 - value={jump()} 262 - onInput={(event) => setJump(event.currentTarget.value)} 263 - placeholder="mary.my.id/atcute" 264 - class={inputStyles()} 265 - /> 266 - <button type="submit" class={clsx(buttonStyles('primary'), 'shrink-0')}> 267 - open 268 - </button> 269 - </div> 270 - </form> 271 - 272 - <div class="mt-8"> 273 - <div class="text-sm font-medium mb-3">sample repositories</div> 274 - <div class="grid gap-2 sm:grid-cols-2"> 275 - <For each={SAMPLE_REPOS}> 276 - {(repo) => ( 277 - <A href={`/${repo.owner}/${repo.slug}`} class={cardStyles('p-4 hover:bg-gray-50 dark:hover:bg-gray-700/30 no-underline')}> 278 - <div class="font-medium"> 279 - {repo.owner}/{repo.slug} 280 - </div> 281 - </A> 282 - )} 283 - </For> 284 - </div> 285 - </div> 286 - </div> 287 - 288 - <div class="flex flex-col gap-4"> 289 - <div class={cardStyles('p-6')}> 290 - <div class="text-sm font-medium mb-3">recent</div> 291 - <Show 292 - when={recentRepos().length > 0} 293 - fallback={<div class="text-sm text-gray-500 dark:text-gray-400">No recent repositories.</div>} 294 - > 295 - <div class="flex flex-col gap-2"> 296 - <For each={recentRepos()}> 297 - {(repo) => ( 298 - <A href={`/${repo.owner}/${repo.slug}`} class="no-underline hover:no-underline rounded border border-gray-200 dark:border-gray-700 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30"> 299 - <div class="font-medium">{repo.title}</div> 300 - <div class="text-xs text-gray-500 dark:text-gray-400">{formatRelativeTime(repo.updatedAt)}</div> 301 - </A> 302 - )} 303 - </For> 304 - </div> 305 - </Show> 306 - </div> 307 - 308 - <Show when={auth.currentDid()}> 309 - <div class={cardStyles('p-6')}> 310 - <div class="text-sm font-medium mb-3">your repositories</div> 311 - <Show 312 - when={!ownReposQuery.isLoading} 313 - fallback={<LoadingState label="Loading repositories…" />} 314 - > 315 - <div class="flex flex-col gap-2"> 316 - <For each={ownReposQuery.data ?? []}> 317 - {(repo) => ( 318 - <A 319 - href={`/${auth.currentDid()}/${repo.value.name?.trim() || repo.rkey}`} 320 - class="no-underline hover:no-underline rounded border border-gray-200 dark:border-gray-700 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30" 321 - > 322 - <div class="font-medium">{repo.value.name?.trim() || repo.rkey}</div> 323 - <div class="text-xs text-gray-500 dark:text-gray-400"> 324 - {repo.value.description || repo.value.repoDid} 325 - </div> 326 - </A> 327 - )} 328 - </For> 329 - </div> 330 - </Show> 331 - </div> 332 - </Show> 333 - </div> 334 - </section> 335 - ); 336 - }; 337 - 338 - const OAuthCallbackPage: Component = () => { 339 - const auth = useAuth(); 340 - const navigate = useNavigate(); 341 - 342 - onMount(() => { 343 - void (async () => { 344 - try { 345 - const target = await auth.completeSignIn(); 346 - navigate(target, { replace: true }); 347 - } catch { 348 - // error already captured in auth state 349 - } 350 - })(); 351 - }); 352 - 353 - return ( 354 - <div class={cardStyles('p-10 text-center')}> 355 - <LoadingState label={auth.error() ?? 'Completing OAuth sign-in…'} /> 356 - </div> 357 - ); 358 - }; 359 - 360 - const NotFoundPage: Component = () => ( 361 - <div class={cardStyles('p-10 text-center')}> 362 - <h1 class="text-2xl font-bold m-0">not found</h1> 363 - </div> 364 25 ); 365 26 366 27 export default App;
+104 -26
src/components/repo.tsx
··· 592 592 { additions: 0, deletions: 0 }, 593 593 ); 594 594 595 - const fileTreeRows = (files: DiffFile[]) => 596 - files.map((file) => { 595 + type DiffFileTreeNode = { 596 + name: string; 597 + path: string; 598 + file?: DiffFile; 599 + children?: DiffFileTreeNode[]; 600 + }; 601 + 602 + const sortDiffFileTree = (nodes: DiffFileTreeNode[]): DiffFileTreeNode[] => 603 + nodes 604 + .sort((a, b) => a.name.localeCompare(b.name)) 605 + .map((node) => ({ 606 + ...node, 607 + children: node.children ? sortDiffFileTree(node.children) : undefined, 608 + })); 609 + 610 + const diffFileTree = (files: DiffFile[]) => { 611 + const roots: DiffFileTreeNode[] = []; 612 + 613 + for (const file of files) { 597 614 const parts = file.path.split('/').filter(Boolean); 598 - return { 599 - file, 600 - depth: Math.max(parts.length - 1, 0), 601 - name: parts.at(-1) ?? file.path, 602 - folder: parts.length > 1 ? parts.slice(0, -1).join('/') : '', 603 - }; 604 - }); 615 + const pathParts = parts.length > 0 ? parts : [file.path || 'patch']; 616 + let siblings = roots; 617 + let path = ''; 618 + 619 + for (const [index, part] of pathParts.entries()) { 620 + path = path ? `${path}/${part}` : part; 621 + const isFile = index === pathParts.length - 1; 622 + 623 + if (isFile) { 624 + siblings.push({ name: part, path: file.path, file }); 625 + continue; 626 + } 627 + 628 + let node = siblings.find((child) => !child.file && child.name === part); 629 + if (!node) { 630 + node = { name: part, path, children: [] }; 631 + siblings.push(node); 632 + } 633 + 634 + siblings = node.children!; 635 + } 636 + } 637 + 638 + return sortDiffFileTree(roots); 639 + }; 640 + 641 + const diffFileElementId = (path: string) => 642 + `diff-${Array.from(path, (char) => char.codePointAt(0)!.toString(16).padStart(4, '0')).join('-')}`; 643 + 644 + const scrollToDiffFile = (path: string, event: MouseEvent) => { 645 + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; 646 + 647 + const target = document.getElementById(diffFileElementId(path)); 648 + if (!target) return; 649 + 650 + event.preventDefault(); 651 + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); 652 + 653 + const hash = `#${target.id}`; 654 + if (window.location.hash !== hash) { 655 + window.history.pushState(null, '', `${window.location.pathname}${window.location.search}${hash}`); 656 + } 657 + }; 658 + 659 + const DiffFileTreeNodes: Component<{ nodes: DiffFileTreeNode[] }> = (props) => ( 660 + <div class="untangled-pr-file-tree-list"> 661 + <For each={props.nodes}>{(node) => <DiffFileTreeNodeView node={node} />}</For> 662 + </div> 663 + ); 664 + 665 + const DiffFileTreeNodeView: Component<{ node: DiffFileTreeNode }> = (props) => ( 666 + <div class="untangled-pr-file-tree-node"> 667 + <Show 668 + when={props.node.file} 669 + fallback={ 670 + <> 671 + <div class="untangled-pr-file-tree-entry" title={props.node.path}> 672 + <Folder class="size-4 shrink-0" /> 673 + <span class="truncate">{props.node.name}</span> 674 + </div> 675 + <Show when={props.node.children?.length}> 676 + <div class="untangled-pr-file-tree-children"> 677 + <DiffFileTreeNodes nodes={props.node.children!} /> 678 + </div> 679 + </Show> 680 + </> 681 + } 682 + > 683 + {(file) => ( 684 + <a 685 + href={`#${diffFileElementId(file().path)}`} 686 + class="untangled-pr-file-tree-entry untangled-pr-file-tree-link" 687 + title={file().path} 688 + onClick={(event) => scrollToDiffFile(file().path, event)} 689 + > 690 + <FileText class="size-4 shrink-0" /> 691 + <span class="truncate">{props.node.name}</span> 692 + </a> 693 + )} 694 + </Show> 695 + </div> 696 + ); 605 697 606 698 const DiffStatPill: Component<{ additions: number; deletions: number }> = (props) => ( 607 699 <div class="untangled-diff-stat-pill"> ··· 617 709 export const PullDiffView: Component<{ patch: string; roundLabel: string; history: JSX.Element }> = (props) => { 618 710 const files = createMemo(() => parseDiffFiles(props.patch)); 619 711 const stats = createMemo(() => totalDiffStats(files())); 620 - const rows = createMemo(() => fileTreeRows(files())); 712 + const tree = createMemo(() => diffFileTree(files())); 621 713 const [expanded, setExpanded] = createSignal(true); 622 714 const [filesVisible, setFilesVisible] = createSignal(true); 623 715 const [historyVisible, setHistoryVisible] = createSignal(true); ··· 681 773 > 682 774 <Show when={filesVisible()}> 683 775 <aside class="untangled-pr-file-tree" aria-label="changed files"> 684 - <For each={rows()}> 685 - {(row) => ( 686 - <a 687 - href={`#diff-${encodeURIComponent(row.file.path)}`} 688 - class="untangled-pr-file-tree-row" 689 - style={{ 'padding-left': `${0.75 + row.depth * 0.85}rem` }} 690 - title={row.file.path} 691 - > 692 - <Show when={row.folder} fallback={<FileText class="size-4 shrink-0" />}> 693 - <Folder class="size-4 shrink-0" /> 694 - </Show> 695 - <span class="truncate">{row.name}</span> 696 - </a> 697 - )} 698 - </For> 776 + <DiffFileTreeNodes nodes={tree()} /> 699 777 </aside> 700 778 </Show> 701 779 702 780 <div class="untangled-pr-diff-files"> 703 781 <For each={files()}> 704 782 {(file) => ( 705 - <details id={`diff-${encodeURIComponent(file.path)}`} class="untangled-pr-diff-file" open={expanded()}> 783 + <details id={diffFileElementId(file.path)} class="untangled-pr-diff-file" open={expanded()}> 706 784 <summary class="untangled-pr-diff-file-header"> 707 785 <div class="flex min-w-0 items-center gap-2"> 708 786 <ChevronRight class="untangled-pr-diff-chevron size-4 shrink-0" />
+37 -11
src/index.css
··· 98 98 top: 0; 99 99 z-index: 30; 100 100 display: grid; 101 - grid-template-columns: 11.5rem minmax(0, 1fr) 29.5rem; 101 + grid-template-columns: 15.5rem minmax(0, 1fr) 29.5rem; 102 102 gap: 1rem; 103 103 align-items: center; 104 104 padding: 0.5rem; ··· 110 110 } 111 111 112 112 .untangled-pr-diff-toolbar-history-collapsed { 113 - grid-template-columns: 11.5rem minmax(0, 1fr) 2.125rem; 113 + grid-template-columns: 15.5rem minmax(0, 1fr) 2.125rem; 114 114 } 115 115 116 116 .untangled-pr-diff-toolbar-sidebar-collapsed.untangled-pr-diff-toolbar-history-collapsed { ··· 199 199 200 200 .untangled-pr-diff-grid { 201 201 display: grid; 202 - grid-template-columns: 11.5rem minmax(0, 1fr) 29.5rem; 202 + grid-template-columns: 15.5rem minmax(0, 1fr) 29.5rem; 203 203 gap: 1rem; 204 204 align-items: start; 205 205 } ··· 209 209 } 210 210 211 211 .untangled-pr-diff-grid-history-collapsed { 212 - grid-template-columns: 11.5rem minmax(0, 1fr); 212 + grid-template-columns: 15.5rem minmax(0, 1fr); 213 213 } 214 214 215 215 .untangled-pr-diff-grid-sidebar-collapsed.untangled-pr-diff-grid-history-collapsed { ··· 226 226 border-top: 0; 227 227 border-radius: 0 0 0.25rem 0.25rem; 228 228 background: rgb(255 255 255); 229 - padding: 0.625rem 0 1.5rem; 229 + padding: 0.75rem 0.75rem 1.5rem; 230 230 box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 231 231 } 232 232 233 - .untangled-pr-file-tree-row { 233 + .untangled-pr-file-tree-list { 234 + display: flex; 235 + min-width: 0; 236 + flex-direction: column; 237 + gap: 0.125rem; 238 + } 239 + 240 + .untangled-pr-file-tree-node { 241 + min-width: 0; 242 + } 243 + 244 + .untangled-pr-file-tree-children { 245 + margin-left: 1rem; 246 + border-left: 1px solid rgb(209 213 219); 247 + padding-left: 0.75rem; 248 + } 249 + 250 + .untangled-pr-file-tree-entry { 234 251 display: flex; 235 252 min-width: 0; 236 253 align-items: center; 237 254 gap: 0.375rem; 238 - padding-top: 0.25rem; 255 + min-height: 1.5rem; 256 + border-radius: 0.25rem; 257 + padding-top: 0.125rem; 239 258 padding-right: 0.5rem; 240 - padding-bottom: 0.25rem; 259 + padding-bottom: 0.125rem; 260 + padding-left: 0.25rem; 241 261 color: rgb(55 65 81); 242 262 font-size: 0.875rem; 243 263 text-decoration: none; 244 264 } 245 265 246 - .untangled-pr-file-tree-row:hover { 266 + .untangled-pr-file-tree-link:hover, 267 + .untangled-pr-file-tree-entry:hover { 247 268 background: rgb(243 244 246); 248 269 text-decoration: none; 249 270 } ··· 256 277 } 257 278 258 279 .untangled-pr-diff-file { 280 + scroll-margin-top: 4rem; 259 281 overflow: hidden; 260 282 border: 1px solid rgb(209 213 219); 261 283 border-radius: 0.25rem; ··· 436 458 color: rgb(248 113 113); 437 459 } 438 460 439 - .untangled-pr-file-tree-row { 461 + .untangled-pr-file-tree-entry { 440 462 color: rgb(229 231 235); 441 463 } 442 464 443 - .untangled-pr-file-tree-row:hover { 465 + .untangled-pr-file-tree-children { 466 + border-color: rgb(75 85 99); 467 + } 468 + 469 + .untangled-pr-file-tree-entry:hover { 444 470 background: rgb(31 41 55); 445 471 } 446 472
+146
src/layout.tsx
··· 1 + import clsx from 'clsx'; 2 + import { Bell, LogOut } from 'lucide-solid'; 3 + import { A } from '@solidjs/router'; 4 + import { createQuery } from '@tanstack/solid-query'; 5 + import { For, Show, createSignal, type Component, type JSX } from 'solid-js'; 6 + 7 + import { Avatar, buttonStyles, inputStyles } from './components/common'; 8 + import { resolveActor } from './lib/api'; 9 + import { useAuth } from './lib/auth'; 10 + import { useLiveEvents, type LiveEvent } from './lib/live-events'; 11 + import { formatRelativeTime } from './lib/repo-utils'; 12 + 13 + const eventLabel = (event: LiveEvent) => { 14 + if (event.source === 'sh.tangled.repo.issue:repo') { 15 + return `new issue on ${event.subject}`; 16 + } 17 + 18 + if (event.source === 'sh.tangled.repo.pull:target.repo') { 19 + return `new pull on ${event.subject}`; 20 + } 21 + 22 + return `${event.source} -> ${event.subject}`; 23 + }; 24 + 25 + const Topbar: Component = () => { 26 + const auth = useAuth(); 27 + const live = useLiveEvents(); 28 + const [identifier, setIdentifier] = createSignal(''); 29 + 30 + const viewerQuery = createQuery(() => ({ 31 + queryKey: ['viewer', auth.currentDid()], 32 + enabled: Boolean(auth.currentDid()), 33 + queryFn: async () => resolveActor(auth.currentDid()!), 34 + })); 35 + 36 + const onSignIn = async (event: SubmitEvent) => { 37 + event.preventDefault(); 38 + await auth.signIn(identifier()); 39 + }; 40 + 41 + return ( 42 + <nav class="mx-auto space-x-4 px-6 py-2"> 43 + <div class="flex justify-between p-0 items-center"> 44 + <A href="/" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 45 + <img src="/static/logos/dolly.svg" alt="" class="size-8" /> 46 + <span class="font-bold text-xl">untangled</span> 47 + <span class="font-normal text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">alpha</span> 48 + </A> 49 + 50 + <div class="flex items-center gap-4"> 51 + <Show 52 + when={auth.currentDid()} 53 + fallback={ 54 + <details class="relative"> 55 + <summary class={clsx(buttonStyles(), 'cursor-pointer list-none')}>login</summary> 56 + <div class="absolute right-0 mt-3 w-72 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-lg z-50"> 57 + <form onSubmit={onSignIn} class="flex flex-col gap-3"> 58 + <input 59 + value={identifier()} 60 + onInput={(event) => setIdentifier(event.currentTarget.value)} 61 + placeholder="handle or did" 62 + class={inputStyles()} 63 + /> 64 + <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')}> 65 + continue with OAuth 66 + </button> 67 + <Show when={auth.error()}> 68 + <p class="text-sm text-red-500">{auth.error()}</p> 69 + </Show> 70 + </form> 71 + </div> 72 + </details> 73 + } 74 + > 75 + <details class="relative"> 76 + <summary 77 + class="cursor-pointer list-none flex items-center gap-2" 78 + onClick={() => live.markRead()} 79 + > 80 + <div class="relative"> 81 + <Bell class="size-5 text-gray-500 dark:text-gray-400" /> 82 + <Show when={live.unread() > 0}> 83 + <span class="untangled-unread-badge absolute -top-2 -right-2 min-w-5 h-5 rounded-full bg-blue-600 text-white flex items-center justify-center px-1"> 84 + {Math.min(live.unread(), 99)} 85 + </span> 86 + </Show> 87 + </div> 88 + </summary> 89 + <div class="absolute right-0 mt-3 w-80 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg z-50 overflow-hidden"> 90 + <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 font-medium">live activity</div> 91 + <div class="max-h-96 overflow-auto"> 92 + <Show 93 + when={live.events().length > 0} 94 + fallback={<div class="px-4 py-6 text-sm text-gray-500 dark:text-gray-400">No live events yet.</div>} 95 + > 96 + <For each={live.events()}> 97 + {(event) => ( 98 + <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 text-sm"> 99 + <div class="font-medium break-all">{eventLabel(event)}</div> 100 + <div class="text-gray-500 dark:text-gray-400">{formatRelativeTime(event.receivedAt)}</div> 101 + </div> 102 + )} 103 + </For> 104 + </Show> 105 + </div> 106 + </div> 107 + </details> 108 + 109 + <details class="relative"> 110 + <summary class="cursor-pointer list-none flex items-center gap-2"> 111 + <Avatar did={auth.currentDid()!} size="size-8 md:size-6" /> 112 + <span class="hidden md:inline">{viewerQuery.data?.handle ?? auth.currentDid()}</span> 113 + </summary> 114 + <div class="absolute right-0 mt-4 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50 text-sm w-56"> 115 + <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> 116 + <div class="font-medium truncate">{viewerQuery.data?.handle ?? auth.currentDid()}</div> 117 + <div class="text-gray-500 dark:text-gray-400 truncate">{auth.currentDid()}</div> 118 + </div> 119 + <button 120 + type="button" 121 + class="w-full px-4 py-3 flex items-center gap-2 text-left hover:bg-gray-50 hover:dark:bg-gray-700/50" 122 + onClick={() => void auth.signOut()} 123 + > 124 + <LogOut class="size-4" /> 125 + logout 126 + </button> 127 + </div> 128 + </details> 129 + </Show> 130 + </div> 131 + </div> 132 + </nav> 133 + ); 134 + }; 135 + 136 + export const RootShell: Component<{ children?: JSX.Element }> = (props) => ( 137 + <div class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white"> 138 + <header class="untangled-safe-header w-full shadow-sm dark:text-white bg-white dark:bg-gray-800" style="z-index: 20;"> 139 + <Topbar /> 140 + </header> 141 + 142 + <div class="flex-grow"> 143 + <div class="untangled-shell mx-auto flex flex-col gap-4">{props.children}</div> 144 + </div> 145 + </div> 146 + );
+90 -1415
src/lib/api.ts
··· 1 - import { Client, ClientResponseError, ok, simpleFetchHandler } from '@atcute/client'; 2 - import { 3 - type ActorResolver, 4 - type ResolveActorOptions, 5 - type ResolvedActor, 6 - } from '@atcute/identity-resolver'; 7 - import type { 8 - ActorIdentifier, 9 - Did, 10 - Nsid, 11 - ResourceUri, 12 - } from '@atcute/lexicons/syntax'; 13 - import type { GenericUri } from '@atcute/lexicons/syntax'; 14 - import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 15 - import { 16 - ShTangledActorProfile, 17 - ShTangledFeedStar, 18 - ShTangledRepo, 19 - ShTangledRepoIssue, 20 - ShTangledRepoIssueComment, 21 - ShTangledRepoIssueState, 22 - ShTangledRepoPull, 23 - ShTangledRepoPullComment, 24 - ShTangledRepoPullStatus, 25 - } from '@atcute/tangled'; 26 - import { gunzipSync, gzipSync, strFromU8, strToU8 } from 'fflate'; 1 + export { 2 + CONSTELLATION_SERVICE, 3 + PUBLIC_API_SERVICE, 4 + SLINGSHOT_SERVICE, 5 + SPACEDUST_SERVICE, 6 + TANGLED_OAUTH_SCOPE, 7 + } from './api/constants'; 8 + export { 9 + createAuthRpc, 10 + identityResolver, 11 + resolveActor, 12 + resolveAvatarUrl, 13 + } from './api/identity'; 14 + export { 15 + extractBlobCid, 16 + parseAtUri, 17 + } from './api/records'; 18 + export { 19 + buildBlobDataUrl, 20 + compareBranches, 21 + decodeBlobText, 22 + getRepo, 23 + getRepoBlob, 24 + getRepoBranches, 25 + getRepoDefaultBranch, 26 + getRepoLanguages, 27 + getRepoLog, 28 + getRepoTags, 29 + getRepoTree, 30 + listRepoRecords, 31 + } from './api/repos'; 32 + export { 33 + createRepoStar, 34 + deleteRepoStar, 35 + getRepoStarCount, 36 + getRepoStarSummary, 37 + } from './api/stars'; 38 + export { 39 + createIssue, 40 + createIssueComment, 41 + getIssue, 42 + listIssues, 43 + listIssuesPage, 44 + setIssueState, 45 + } from './api/issues'; 46 + export { 47 + createPull, 48 + createPullComment, 49 + fetchPullRoundPatch, 50 + getPull, 51 + listPulls, 52 + listPullsPage, 53 + setPullStatus, 54 + } from './api/pulls'; 27 55 28 - import type {} from '@atcute/atproto'; 29 - import type {} from '@atcute/microcosm'; 30 - import type {} from '@atcute/tangled'; 31 - 32 - export const PUBLIC_API_SERVICE = 'https://public.api.bsky.app'; 33 - export const CONSTELLATION_SERVICE = 'https://constellation.microcosm.blue'; 34 - export const SLINGSHOT_SERVICE = 'https://slingshot.microcosm.blue'; 35 - export const SPACEDUST_SERVICE = 'wss://spacedust.microcosm.blue/subscribe'; 36 - 37 - export const TANGLED_OAUTH_SCOPE = import.meta.env.VITE_OAUTH_SCOPE; 38 - 39 - const REPO_COLLECTION: Nsid = 'sh.tangled.repo'; 40 - const STAR_COLLECTION: Nsid = 'sh.tangled.feed.star'; 41 - const ACTOR_PROFILE_COLLECTION: Nsid = 'sh.tangled.actor.profile'; 42 - const APP_BSKY_PROFILE_COLLECTION: Nsid = 'app.bsky.actor.profile'; 43 - const ISSUE_COLLECTION: Nsid = 'sh.tangled.repo.issue'; 44 - const ISSUE_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.issue.comment'; 45 - const ISSUE_STATE_COLLECTION: Nsid = 'sh.tangled.repo.issue.state'; 46 - const PULL_COLLECTION: Nsid = 'sh.tangled.repo.pull'; 47 - const PULL_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.pull.comment'; 48 - const PULL_STATUS_COLLECTION: Nsid = 'sh.tangled.repo.pull.status'; 49 - 50 - class SlingshotActorResolver implements ActorResolver { 51 - async resolve(actor: ActorIdentifier, options?: ResolveActorOptions): Promise<ResolvedActor> { 52 - const resolved = await ok( 53 - getRpc(SLINGSHOT_SERVICE).get('blue.microcosm.identity.resolveMiniDoc', { 54 - params: { 55 - identifier: actor, 56 - }, 57 - signal: options?.signal, 58 - }), 59 - ); 60 - 61 - return { 62 - did: resolved.did, 63 - handle: resolved.handle, 64 - pds: normalizeServiceUrl(resolved.pds), 65 - }; 66 - } 67 - } 68 - 69 - export const identityResolver = new SlingshotActorResolver(); 70 - 71 - export interface RepoContext { 72 - owner: ResolvedActor; 73 - record: RepoRecord; 74 - repoDid: Did; 75 - slug: string; 76 - rkey: string; 77 - knot: string; 78 - } 79 - 80 - export interface RepoRecord { 81 - uri: ResourceUri; 82 - cid: string; 83 - rkey: string; 84 - value: ShTangledRepo.Main; 85 - } 86 - 87 - export interface HydratedRecord<T> { 88 - author: ResolvedActor; 89 - collection: Nsid; 90 - rkey: string; 91 - uri: ResourceUri; 92 - cid?: string; 93 - value: T; 94 - } 95 - 96 - export interface IssueSummary extends HydratedRecord<ShTangledRepoIssue.Main> { 97 - number: number; 98 - state: 'open' | 'closed'; 99 - } 100 - 101 - export interface PullSummary extends HydratedRecord<ShTangledRepoPull.Main> { 102 - number: number; 103 - state: 'open' | 'closed' | 'merged'; 104 - } 105 - 106 - export interface IssueComment extends HydratedRecord<ShTangledRepoIssueComment.Main> {} 107 - export interface PullComment extends HydratedRecord<ShTangledRepoPullComment.Main> {} 108 - 109 - export interface IssueDetail { 110 - issue: IssueSummary; 111 - comments: IssueComment[]; 112 - } 113 - 114 - export interface PullDetail { 115 - pull: PullSummary; 116 - comments: PullComment[]; 117 - } 118 - 119 - export interface PaginatedResult<T> { 120 - items: T[]; 121 - totalCount?: number; 122 - hasNext: boolean; 123 - } 124 - 125 - export interface TreeResponse { 126 - ref: string; 127 - parent?: string; 128 - dotdot?: string; 129 - lastCommit?: CommitHeadline; 130 - readme?: { 131 - contents: string; 132 - filename: string; 133 - }; 134 - files: TreeEntry[]; 135 - } 136 - 137 - export interface TreeEntry { 138 - name: string; 139 - mode: string; 140 - size: number; 141 - last_commit?: CommitHeadline; 142 - } 143 - 144 - export interface CommitHeadline { 145 - hash: string; 146 - message: string; 147 - when: string; 148 - author?: { 149 - email: string; 150 - name: string; 151 - when: string; 152 - }; 153 - } 154 - 155 - export interface BranchResponse { 156 - branches: BranchEntry[]; 157 - } 158 - 159 - export interface DefaultBranchResponse { 160 - name: string; 161 - hash?: string; 162 - when?: string; 163 - } 164 - 165 - export interface BranchEntry { 166 - reference: { 167 - name: string; 168 - hash: string; 169 - }; 170 - commit?: { 171 - Message: string; 172 - Author?: { 173 - Name: string; 174 - Email: string; 175 - When: string; 176 - }; 177 - Committer?: { 178 - Name: string; 179 - Email: string; 180 - When: string; 181 - }; 182 - }; 183 - is_default?: boolean; 184 - } 185 - 186 - export interface TagResponse { 187 - tags: Array<{ 188 - name: string; 189 - hash: string; 190 - message?: string; 191 - tag?: { 192 - Name: string; 193 - Message: string; 194 - Tagger?: { 195 - Name: string; 196 - Email: string; 197 - When: string; 198 - }; 199 - }; 200 - }>; 201 - } 202 - 203 - export interface LanguageResponse { 204 - ref: string; 205 - totalFiles?: number; 206 - totalSize?: number; 207 - languages: Array<{ 208 - name: string; 209 - percentage: number; 210 - size: number; 211 - color?: string; 212 - }>; 213 - } 214 - 215 - export interface LogResponse { 216 - ref: string; 217 - total: number; 218 - page: number; 219 - per_page: number; 220 - log: boolean; 221 - commits: Array<{ 222 - this: string; 223 - message: string; 224 - author?: { 225 - Name: string; 226 - Email: string; 227 - When: string; 228 - }; 229 - committer?: { 230 - Name: string; 231 - Email: string; 232 - When: string; 233 - }; 234 - pgp_signature?: string; 235 - parent?: string; 236 - parent_hashes?: number[][]; 237 - }>; 238 - } 239 - 240 - export interface BlobResponse { 241 - path: string; 242 - ref: string; 243 - content?: string; 244 - encoding?: 'base64' | 'utf-8'; 245 - fileTooLarge?: boolean; 246 - isBinary?: boolean; 247 - mimeType?: string; 248 - size?: number; 249 - lastCommit?: CommitHeadline; 250 - submodule?: { 251 - name: string; 252 - url: string; 253 - branch?: string; 254 - }; 255 - } 256 - 257 - export interface ComparePatch { 258 - Title: string; 259 - Body: string; 260 - Raw: string; 261 - SHA: string; 262 - Author?: string; 263 - Committer?: string; 264 - AuthorDate?: string; 265 - CommitterDate?: string; 266 - SubjectPrefix?: string; 267 - RawHeaders?: string; 268 - BodyAppendix?: string; 269 - } 270 - 271 - export interface CompareResponse { 272 - rev1: string; 273 - rev2: string; 274 - patch: string; 275 - format_patch: ComparePatch[]; 276 - } 277 - 278 - export interface CreateIssueInput { 279 - title: string; 280 - body: string; 281 - } 282 - 283 - export interface CreatePullInput { 284 - title: string; 285 - body: string; 286 - targetBranch: string; 287 - sourceBranch: string; 288 - patch: string; 289 - } 290 - 291 - export interface RecordCreationResult { 292 - uri: ResourceUri; 293 - rkey: string; 294 - } 295 - 296 - interface BacklinkRecordRef { 297 - did: Did; 298 - collection: Nsid; 299 - rkey: string; 300 - } 301 - 302 - interface ProfileWithAvatar { 303 - avatar?: unknown; 304 - } 305 - 306 - const actorCache = new Map<string, Promise<ResolvedActor>>(); 307 - const rpcCache = new Map<string, Client>(); 308 - 309 - const normalizeServiceUrl = (input: string): string => { 310 - if (input.startsWith('http://') || input.startsWith('https://')) { 311 - return input; 312 - } 313 - 314 - return `https://${input}`; 315 - }; 316 - 317 - const getRpc = (service: string): Client => { 318 - const normalized = normalizeServiceUrl(service); 319 - const existing = rpcCache.get(normalized); 320 - if (existing) { 321 - return existing; 322 - } 323 - 324 - const client = new Client({ 325 - handler: simpleFetchHandler({ 326 - service: normalized, 327 - }), 328 - }); 329 - 330 - rpcCache.set(normalized, client); 331 - return client; 332 - }; 333 - 334 - export const createAuthRpc = (agent: OAuthUserAgent): Client => 335 - new Client({ 336 - handler: agent, 337 - }); 338 - 339 - export const resolveActor = async (identifier: string): Promise<ResolvedActor> => { 340 - const key = identifier.toLowerCase(); 341 - const cached = actorCache.get(key); 342 - if (cached) { 343 - return cached; 344 - } 345 - 346 - const promise = identityResolver.resolve(asActorIdentifier(identifier)); 347 - actorCache.set(key, promise); 348 - return promise; 349 - }; 350 - 351 - const fetchAllRepoRecords = async ( 352 - actor: ResolvedActor, 353 - collection: Nsid, 354 - ): Promise<Array<{ uri: ResourceUri; cid: string; value: unknown }>> => { 355 - const rpc = getRpc(actor.pds); 356 - const records: Array<{ uri: ResourceUri; cid: string; value: unknown }> = []; 357 - let cursor: string | undefined; 358 - 359 - do { 360 - const page = await ok( 361 - rpc.get('com.atproto.repo.listRecords', { 362 - params: { 363 - repo: actor.did, 364 - collection, 365 - limit: 100, 366 - cursor, 367 - reverse: true, 368 - }, 369 - }), 370 - ); 371 - 372 - records.push(...page.records); 373 - cursor = page.cursor; 374 - } while (cursor); 375 - 376 - return records; 377 - }; 378 - 379 - export const listRepoRecords = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => { 380 - const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; 381 - const records = await fetchAllRepoRecords(actor, REPO_COLLECTION); 382 - 383 - return records.map((record) => ({ 384 - uri: record.uri, 385 - cid: record.cid, 386 - rkey: parseAtUri(record.uri).rkey, 387 - value: record.value as ShTangledRepo.Main, 388 - })); 389 - }; 390 - 391 - export interface RepoStarSummary { 392 - count: number; 393 - isStarred: boolean; 394 - currentUserStarRkey?: string; 395 - } 396 - 397 - const STAR_BACKLINK_SOURCES = [ 398 - 'sh.tangled.feed.star:subject.did', 399 - 'sh.tangled.feed.star:subjectDid', 400 - ]; 401 - 402 - export const getRepoStarSummary = async (repo: RepoContext, viewerDid?: Did | null): Promise<RepoStarSummary> => { 403 - const starrers = new Set<Did>(); 404 - const backlinkDidGroups = await Promise.all(STAR_BACKLINK_SOURCES.map((source) => getBacklinkDids(repo.repoDid, source))); 405 - 406 - for (const did of backlinkDidGroups.flat()) { 407 - starrers.add(did); 408 - } 409 - 410 - const currentUserStar = 411 - viewerDid && starrers.has(viewerDid) 412 - ? await findBacklinkRefByDid(repo.repoDid, STAR_BACKLINK_SOURCES, viewerDid) 413 - : undefined; 414 - 415 - return { 416 - count: starrers.size, 417 - isStarred: !!currentUserStar, 418 - currentUserStarRkey: currentUserStar?.rkey, 419 - }; 420 - }; 421 - 422 - export const getRepoStarCount = async (repo: RepoContext): Promise<number> => { 423 - const summary = await getRepoStarSummary(repo); 424 - return summary.count; 425 - }; 426 - 427 - export const createRepoStar = async (agent: OAuthUserAgent, repo: RepoContext): Promise<RecordCreationResult> => { 428 - const rpc = createAuthRpc(agent); 429 - const record: ShTangledFeedStar.Main = { 430 - $type: 'sh.tangled.feed.star', 431 - subject: { 432 - $type: 'sh.tangled.feed.star#repo', 433 - did: repo.repoDid, 434 - }, 435 - createdAt: new Date().toISOString(), 436 - }; 437 - const result = await ok( 438 - rpc.post('com.atproto.repo.createRecord', { 439 - input: { 440 - repo: agent.sub, 441 - collection: STAR_COLLECTION, 442 - record, 443 - }, 444 - }), 445 - ); 446 - 447 - return { 448 - uri: result.uri, 449 - rkey: parseAtUri(result.uri).rkey, 450 - }; 451 - }; 452 - 453 - export const deleteRepoStar = async (agent: OAuthUserAgent, rkey: string): Promise<void> => { 454 - const rpc = createAuthRpc(agent); 455 - await ok( 456 - rpc.post('com.atproto.repo.deleteRecord', { 457 - input: { 458 - repo: agent.sub, 459 - collection: STAR_COLLECTION, 460 - rkey, 461 - }, 462 - }), 463 - ); 464 - }; 465 - 466 - export const getRepo = async (ownerIdentifier: string, repoSlug: string): Promise<RepoContext> => { 467 - const owner = await resolveActor(ownerIdentifier); 468 - const all = await listRepoRecords(owner); 469 - const wanted = repoSlug.trim().toLowerCase(); 470 - const match = all.find((record) => { 471 - const name = record.value.name?.trim().toLowerCase(); 472 - return name === wanted || record.rkey.toLowerCase() === wanted; 473 - }); 474 - 475 - if (!match) { 476 - throw new Error(`Repository not found: ${ownerIdentifier}/${repoSlug}`); 477 - } 478 - 479 - if (!match.value.repoDid) { 480 - throw new Error(`Repository is missing repoDid metadata`); 481 - } 482 - 483 - const slug = match.value.name?.trim() || match.rkey; 484 - 485 - return { 486 - owner, 487 - record: match, 488 - repoDid: match.value.repoDid, 489 - slug, 490 - rkey: match.rkey, 491 - knot: normalizeServiceUrl(match.value.knot), 492 - }; 493 - }; 494 - 495 - export const getRepoTree = async ( 496 - repo: RepoContext, 497 - ref: string, 498 - path = '', 499 - ): Promise<TreeResponse> => 500 - (await ok( 501 - getRpc(repo.knot).get('sh.tangled.repo.tree', { 502 - params: { 503 - repo: repo.repoDid, 504 - ref, 505 - path, 506 - }, 507 - }), 508 - )) as TreeResponse; 509 - 510 - export const getRepoBranches = async (repo: RepoContext): Promise<BranchResponse> => 511 - (await ok( 512 - getRpc(repo.knot).get('sh.tangled.repo.branches', { 513 - params: { 514 - repo: repo.repoDid, 515 - }, 516 - as: 'json', 517 - }), 518 - )) as BranchResponse; 519 - 520 - export const getRepoDefaultBranch = async (repo: RepoContext): Promise<DefaultBranchResponse> => 521 - (await ok( 522 - getRpc(repo.knot).get('sh.tangled.repo.getDefaultBranch', { 523 - params: { 524 - repo: repo.repoDid, 525 - }, 526 - }), 527 - )) as DefaultBranchResponse; 528 - 529 - export const getRepoTags = async (repo: RepoContext): Promise<TagResponse> => 530 - (await ok( 531 - getRpc(repo.knot).get('sh.tangled.repo.tags', { 532 - params: { 533 - repo: repo.repoDid, 534 - }, 535 - as: 'json', 536 - }), 537 - )) as TagResponse; 538 - 539 - export const getRepoLanguages = async (repo: RepoContext, ref: string): Promise<LanguageResponse> => 540 - (await ok( 541 - getRpc(repo.knot).get('sh.tangled.repo.languages', { 542 - params: { 543 - repo: repo.repoDid, 544 - ref, 545 - }, 546 - }), 547 - )) as LanguageResponse; 548 - 549 - export const getRepoLog = async (repo: RepoContext, ref: string): Promise<LogResponse> => 550 - (await ok( 551 - getRpc(repo.knot).get('sh.tangled.repo.log', { 552 - params: { 553 - repo: repo.repoDid, 554 - ref, 555 - limit: 8, 556 - }, 557 - as: 'json', 558 - }), 559 - )) as LogResponse; 560 - 561 - export const getRepoBlob = async ( 562 - repo: RepoContext, 563 - ref: string, 564 - path: string, 565 - ): Promise<BlobResponse> => 566 - (await ok( 567 - getRpc(repo.knot).get('sh.tangled.repo.blob', { 568 - params: { 569 - repo: repo.repoDid, 570 - ref, 571 - path, 572 - }, 573 - }), 574 - )) as BlobResponse; 575 - 576 - export const compareBranches = async ( 577 - repo: RepoContext, 578 - rev1: string, 579 - rev2: string, 580 - ): Promise<CompareResponse> => 581 - (await ok( 582 - getRpc(repo.knot).get('sh.tangled.repo.compare', { 583 - params: { 584 - repo: repo.repoDid, 585 - rev1, 586 - rev2, 587 - }, 588 - as: 'json', 589 - }), 590 - )) as CompareResponse; 591 - 592 - const getBacklinksPage = async ( 593 - subject: GenericUri, 594 - source: string, 595 - limit: number, 596 - cursor?: string, 597 - ): Promise<{ records: BacklinkRecordRef[]; cursor?: string; total?: number }> => { 598 - const constellation = getRpc(CONSTELLATION_SERVICE); 599 - const page = await ok( 600 - constellation.get('blue.microcosm.links.getBacklinks', { 601 - params: { 602 - subject, 603 - source, 604 - limit, 605 - ...(cursor ? { cursor } : {}), 606 - }, 607 - }), 608 - ); 609 - 610 - return page as { records: BacklinkRecordRef[]; cursor?: string; total?: number }; 611 - }; 612 - 613 - const getBacklinkDidsPage = async ( 614 - subject: GenericUri, 615 - source: string, 616 - limit: number, 617 - cursor?: string, 618 - ): Promise<{ linking_dids: Did[]; cursor?: string | null; total?: number }> => { 619 - const constellation = getRpc(CONSTELLATION_SERVICE); 620 - const page = await ok( 621 - constellation.get('blue.microcosm.links.getBacklinkDids', { 622 - params: { 623 - subject, 624 - source, 625 - limit, 626 - ...(cursor ? { cursor } : {}), 627 - }, 628 - }), 629 - ); 630 - 631 - return page as { linking_dids: Did[]; cursor?: string | null; total?: number }; 632 - }; 633 - 634 - const getBacklinkDids = async (subject: GenericUri, source: string): Promise<Did[]> => { 635 - const dids: Did[] = []; 636 - let cursor: string | null = null; 637 - 638 - do { 639 - const page = await getBacklinkDidsPage(subject, source, 100, cursor ?? undefined); 640 - dids.push(...page.linking_dids); 641 - cursor = page.cursor ?? null; 642 - } while (cursor); 643 - 644 - return dids; 645 - }; 646 - 647 - const findBacklinkRefByDidForSource = async ( 648 - subject: GenericUri, 649 - source: string, 650 - did: Did, 651 - ): Promise<BacklinkRecordRef | undefined> => { 652 - let cursor: string | null = null; 653 - 654 - do { 655 - const page = await getBacklinksPage(subject, source, 100, cursor ?? undefined); 656 - const ref = page.records.find((record) => record.did === did && record.collection === STAR_COLLECTION); 657 - if (ref) { 658 - return ref; 659 - } 660 - cursor = page.cursor ?? null; 661 - } while (cursor); 662 - }; 663 - 664 - const findBacklinkRefByDid = async ( 665 - subject: GenericUri, 666 - sources: string[], 667 - did: Did, 668 - ): Promise<BacklinkRecordRef | undefined> => { 669 - const refs = await Promise.all(sources.map((source) => findBacklinkRefByDidForSource(subject, source, did))); 670 - return refs.find((ref) => ref); 671 - }; 672 - 673 - const getBacklinks = async (subject: GenericUri, source: string): Promise<BacklinkRecordRef[]> => { 674 - const records: BacklinkRecordRef[] = []; 675 - let cursor: string | null = null; 676 - const constellation = getRpc(CONSTELLATION_SERVICE); 677 - 678 - do { 679 - const page: { records: BacklinkRecordRef[]; cursor?: string | null } = await ok( 680 - constellation.get('blue.microcosm.links.getBacklinks', { 681 - params: { 682 - subject, 683 - source, 684 - limit: 100, 685 - ...(cursor ? { cursor } : {}), 686 - }, 687 - }), 688 - ); 689 - records.push(...page.records); 690 - cursor = page.cursor ?? null; 691 - } while (cursor); 692 - 693 - return records; 694 - }; 695 - 696 - const hydrateRecord = async <T>( 697 - ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, 698 - ): Promise<HydratedRecord<T>> => { 699 - const actor = await resolveActor(ref.did); 700 - const record = await ok( 701 - getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { 702 - params: { 703 - repo: ref.did, 704 - collection: ref.collection, 705 - rkey: ref.rkey, 706 - }, 707 - }), 708 - ); 709 - 710 - return { 711 - author: actor, 712 - collection: ref.collection, 713 - rkey: ref.rkey, 714 - uri: record.uri, 715 - cid: record.cid, 716 - value: record.value as T, 717 - }; 718 - }; 719 - 720 - const fetchRecordValue = async <T>( 721 - ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, 722 - ): Promise<T> => { 723 - const record = await ok( 724 - getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { 725 - params: { 726 - repo: ref.did, 727 - collection: ref.collection, 728 - rkey: ref.rkey, 729 - }, 730 - }), 731 - ); 732 - 733 - return record.value as T; 734 - }; 735 - 736 - const hydrateBacklinks = async <T>(refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<T>>> => 737 - Promise.all(refs.map((ref) => hydrateRecord<T>(ref))); 738 - 739 - const getOptionalRecord = async <T>( 740 - actor: ResolvedActor, 741 - collection: Nsid, 742 - rkey: string, 743 - ): Promise<T | null> => { 744 - try { 745 - const record = await ok( 746 - getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { 747 - params: { 748 - repo: actor.did, 749 - collection, 750 - rkey, 751 - }, 752 - }), 753 - ); 754 - 755 - return record.value as T; 756 - } catch (cause) { 757 - if ( 758 - cause instanceof ClientResponseError && 759 - (cause.status === 400 || cause.status === 404) 760 - ) { 761 - return null; 762 - } 763 - 764 - throw cause; 765 - } 766 - }; 767 - 768 - const toNumberMap = <T extends { uri: string; rkey: string; value: { createdAt?: string } }>( 769 - items: T[], 770 - ): Map<string, number> => { 771 - const ordered = [...items].sort((left, right) => { 772 - const leftTime = left.value.createdAt ? new Date(left.value.createdAt).getTime() : 0; 773 - const rightTime = right.value.createdAt ? new Date(right.value.createdAt).getTime() : 0; 774 - if (leftTime !== rightTime) { 775 - return leftTime - rightTime; 776 - } 777 - 778 - return left.rkey.localeCompare(right.rkey); 779 - }); 780 - 781 - return new Map(ordered.map((item, index) => [item.uri, index + 1])); 782 - }; 783 - 784 - const normalizeIssueState = (value?: string): 'open' | 'closed' => 785 - value?.endsWith('.closed') ? 'closed' : 'open'; 786 - 787 - const normalizePullStatus = (value?: string): 'open' | 'closed' | 'merged' => { 788 - if (value?.endsWith('.merged')) { 789 - return 'merged'; 790 - } 791 - 792 - if (value?.endsWith('.closed')) { 793 - return 'closed'; 794 - } 795 - 796 - return 'open'; 797 - }; 798 - 799 - const latestBacklinkRefByRkey = (refs: BacklinkRecordRef[]): BacklinkRecordRef | undefined => 800 - [...refs].sort((left, right) => left.rkey.localeCompare(right.rkey)).at(-1); 801 - 802 - const BACKLINK_BATCH_LIMIT = 50; 803 - 804 - const listBacklinkedRecordsPage = async <T extends { createdAt?: string }, State extends string>( 805 - subject: GenericUri, 806 - source: string | string[], 807 - options: { offset: number; limit: number; state: State }, 808 - getState: (uri: ResourceUri) => Promise<State>, 809 - ): Promise<PaginatedResult<HydratedRecord<T> & { number: number; state: State }>> => { 810 - const requestedEnd = options.offset + options.limit; 811 - const items: Array<HydratedRecord<T> & { number: number; state: State }> = []; 812 - const sources = Array.isArray(source) ? source : [source]; 813 - const batchLimit = Math.min(BACKLINK_BATCH_LIMIT, Math.max(options.limit + 1, 1)); 814 - const cursors = new Map(sources.map((entry) => [entry, undefined as string | undefined])); 815 - const exhausted = new Set<string>(); 816 - const seenRefs = new Set<string>(); 817 - let matchingSeen = 0; 818 - let scannedSeen = 0; 819 - let total: number | undefined; 820 - 821 - while (items.length <= options.limit) { 822 - const pages = await Promise.all( 823 - sources 824 - .filter((entry) => !exhausted.has(entry)) 825 - .map(async (entry) => ({ 826 - source: entry, 827 - page: await getBacklinksPage(subject, entry, batchLimit, cursors.get(entry)), 828 - })), 829 - ); 830 - 831 - if (pages.length === 0) { 832 - return { items, totalCount: matchingSeen, hasNext: false }; 833 - } 834 - 835 - const refs = pages 836 - .flatMap(({ source: entry, page }) => { 837 - total = sources.length === 1 ? page.total ?? total : undefined; 838 - cursors.set(entry, page.cursor); 839 - if (!page.cursor) { 840 - exhausted.add(entry); 841 - } 842 - return page.records; 843 - }) 844 - .filter((ref) => { 845 - const key = `${ref.did}/${ref.collection}/${ref.rkey}`; 846 - if (seenRefs.has(key)) { 847 - return false; 848 - } 849 - seenRefs.add(key); 850 - return true; 851 - }); 852 - 853 - if (refs.length === 0) { 854 - if (exhausted.size === sources.length) { 855 - return { items, totalCount: matchingSeen, hasNext: false }; 856 - } 857 - continue; 858 - } 859 - 860 - if (pages.every(({ page }) => page.records.length === 0)) { 861 - return { items, totalCount: matchingSeen, hasNext: false }; 862 - } 863 - 864 - const hydrated = await hydrateBacklinks<T>(refs); 865 - hydrated.sort(sortByCreatedAt).reverse(); 866 - const states = await Promise.all(hydrated.map((record) => getState(record.uri))); 867 - 868 - for (let index = 0; index < hydrated.length; index += 1) { 869 - const record = hydrated[index]; 870 - const state = states[index]; 871 - const number = total ? Math.max(total - scannedSeen, 1) : scannedSeen + 1; 872 - scannedSeen += 1; 873 - 874 - if (state !== options.state) { 875 - continue; 876 - } 877 - 878 - if (matchingSeen >= options.offset && matchingSeen < requestedEnd + 1) { 879 - items.push({ ...record, number, state }); 880 - } 881 - 882 - matchingSeen += 1; 883 - if (matchingSeen > requestedEnd) { 884 - return { items: items.slice(0, options.limit), hasNext: true }; 885 - } 886 - } 887 - 888 - if (exhausted.size === sources.length) { 889 - return { items, totalCount: matchingSeen, hasNext: false }; 890 - } 891 - } 892 - 893 - return { items: items.slice(0, options.limit), hasNext: true }; 894 - }; 895 - 896 - const getLatestIssueState = async (issueUri: ResourceUri): Promise<'open' | 'closed'> => { 897 - const refs = await getBacklinks(issueUri, 'sh.tangled.repo.issue.state:issue'); 898 - if (refs.length === 0) { 899 - return 'open'; 900 - } 901 - 902 - const state = await fetchRecordValue<ShTangledRepoIssueState.Main>(latestBacklinkRefByRkey(refs)!); 903 - return normalizeIssueState(state.state); 904 - }; 905 - 906 - const getLatestPullStatus = async (pullUri: ResourceUri): Promise<'open' | 'closed' | 'merged'> => { 907 - const refs = await getBacklinks(pullUri, 'sh.tangled.repo.pull.status:pull'); 908 - if (refs.length === 0) { 909 - return 'open'; 910 - } 911 - 912 - const status = await fetchRecordValue<ShTangledRepoPullStatus.Main>(latestBacklinkRefByRkey(refs)!); 913 - return normalizePullStatus(status.status); 914 - }; 915 - 916 - export const listIssues = async (repo: RepoContext): Promise<IssueSummary[]> => { 917 - const refs = Array.from( 918 - new Map( 919 - ( 920 - await Promise.all([ 921 - getBacklinks(repo.repoDid, 'sh.tangled.repo.issue:repoDid'), 922 - getBacklinks(repo.repoDid, 'sh.tangled.repo.issue:repo'), 923 - ]) 924 - ) 925 - .flat() 926 - .map((ref) => [`${ref.did}/${ref.collection}/${ref.rkey}`, ref] as const), 927 - ).values(), 928 - ); 929 - const issues = await hydrateBacklinks<ShTangledRepoIssue.Main>(refs); 930 - const numbers = toNumberMap(issues); 931 - const states = await Promise.all(issues.map((issue) => getLatestIssueState(issue.uri))); 932 - 933 - return issues 934 - .map((issue, index) => ({ 935 - ...issue, 936 - number: numbers.get(issue.uri) ?? index + 1, 937 - state: states[index], 938 - })) 939 - .sort((left, right) => right.number - left.number); 940 - }; 941 - 942 - export const listIssuesPage = async ( 943 - repo: RepoContext, 944 - options: { offset: number; limit: number; state: 'open' | 'closed' }, 945 - ): Promise<PaginatedResult<IssueSummary>> => 946 - listBacklinkedRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 947 - repo.repoDid, 948 - ['sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo'], 949 - options, 950 - getLatestIssueState, 951 - ); 952 - 953 - export const getIssue = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { 954 - const issues = await listIssues(repo); 955 - const issue = findNumberedRecord(issues, issueRef); 956 - if (!issue) { 957 - throw new Error(`Issue not found`); 958 - } 959 - 960 - const commentRefs = await getBacklinks(issue.uri, 'sh.tangled.repo.issue.comment:issue'); 961 - const comments = await hydrateBacklinks<ShTangledRepoIssueComment.Main>(commentRefs); 962 - comments.sort(sortByCreatedAt); 963 - 964 - return { 965 - issue, 966 - comments, 967 - }; 968 - }; 969 - 970 - export const listPulls = async (repo: RepoContext): Promise<PullSummary[]> => { 971 - const refs = await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo'); 972 - const pulls = await hydrateBacklinks<ShTangledRepoPull.Main>(refs); 973 - const numbers = toNumberMap(pulls); 974 - const states = await Promise.all(pulls.map((pull) => getLatestPullStatus(pull.uri))); 975 - 976 - return pulls 977 - .map((pull, index) => ({ 978 - ...pull, 979 - number: numbers.get(pull.uri) ?? index + 1, 980 - state: states[index], 981 - })) 982 - .sort((left, right) => right.number - left.number); 983 - }; 984 - 985 - export const listPullsPage = async ( 986 - repo: RepoContext, 987 - options: { offset: number; limit: number; state: 'open' | 'closed' | 'merged' }, 988 - ): Promise<PaginatedResult<PullSummary>> => 989 - listBacklinkedRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 990 - repo.repoDid, 991 - 'sh.tangled.repo.pull:target.repo', 992 - options, 993 - getLatestPullStatus, 994 - ); 995 - 996 - export const getPull = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { 997 - const pulls = await listPulls(repo); 998 - const pull = findNumberedRecord(pulls, pullRef); 999 - if (!pull) { 1000 - throw new Error(`Pull request not found`); 1001 - } 1002 - 1003 - const commentRefs = await getBacklinks(pull.uri, 'sh.tangled.repo.pull.comment:pull'); 1004 - const comments = await hydrateBacklinks<ShTangledRepoPullComment.Main>(commentRefs); 1005 - comments.sort(sortByCreatedAt); 1006 - 1007 - return { 1008 - pull, 1009 - comments, 1010 - }; 1011 - }; 1012 - 1013 - export const fetchPullRoundPatch = async ( 1014 - pull: PullSummary, 1015 - roundIndex: number, 1016 - ): Promise<string> => { 1017 - const round = pull.value.rounds[roundIndex]; 1018 - if (!round) { 1019 - throw new Error(`Round ${roundIndex + 1} not found`); 1020 - } 1021 - 1022 - const cid = extractBlobCid(round.patchBlob); 1023 - if (!cid) { 1024 - throw new Error(`Missing patch blob CID`); 1025 - } 1026 - 1027 - const rpc = getRpc(pull.author.pds); 1028 - const bytes = await ok( 1029 - rpc.get('com.atproto.sync.getBlob', { 1030 - params: { 1031 - did: pull.author.did, 1032 - cid, 1033 - }, 1034 - as: 'bytes', 1035 - }), 1036 - ); 1037 - 1038 - return strFromU8(gunzipSync(bytes)); 1039 - }; 1040 - 1041 - export const createIssue = async ( 1042 - agent: OAuthUserAgent, 1043 - repo: RepoContext, 1044 - input: CreateIssueInput, 1045 - ): Promise<RecordCreationResult> => { 1046 - const rpc = createAuthRpc(agent); 1047 - const record: ShTangledRepoIssue.Main = { 1048 - $type: 'sh.tangled.repo.issue', 1049 - repo: repo.repoDid, 1050 - title: input.title.trim(), 1051 - body: optionalString(input.body), 1052 - createdAt: new Date().toISOString(), 1053 - mentions: extractDids(input.body), 1054 - references: extractAtUris(input.body), 1055 - }; 1056 - 1057 - const result = await ok( 1058 - rpc.post('com.atproto.repo.createRecord', { 1059 - input: { 1060 - repo: agent.sub, 1061 - collection: ISSUE_COLLECTION, 1062 - record, 1063 - }, 1064 - }), 1065 - ); 1066 - 1067 - return { 1068 - uri: result.uri, 1069 - rkey: parseAtUri(result.uri).rkey, 1070 - }; 1071 - }; 1072 - 1073 - export const createIssueComment = async ( 1074 - agent: OAuthUserAgent, 1075 - issueUri: ResourceUri, 1076 - body: string, 1077 - replyTo?: ResourceUri, 1078 - ): Promise<RecordCreationResult> => { 1079 - const rpc = createAuthRpc(agent); 1080 - const record: ShTangledRepoIssueComment.Main = { 1081 - $type: 'sh.tangled.repo.issue.comment', 1082 - issue: issueUri, 1083 - body: body.trim(), 1084 - createdAt: new Date().toISOString(), 1085 - mentions: extractDids(body), 1086 - references: extractAtUris(body), 1087 - ...(replyTo ? { replyTo } : {}), 1088 - }; 1089 - 1090 - const result = await ok( 1091 - rpc.post('com.atproto.repo.createRecord', { 1092 - input: { 1093 - repo: agent.sub, 1094 - collection: ISSUE_COMMENT_COLLECTION, 1095 - record, 1096 - }, 1097 - }), 1098 - ); 1099 - 1100 - return { 1101 - uri: result.uri, 1102 - rkey: parseAtUri(result.uri).rkey, 1103 - }; 1104 - }; 1105 - 1106 - export const setIssueState = async ( 1107 - agent: OAuthUserAgent, 1108 - issueUri: ResourceUri, 1109 - state: 'open' | 'closed', 1110 - ): Promise<RecordCreationResult> => { 1111 - const rpc = createAuthRpc(agent); 1112 - const record: ShTangledRepoIssueState.Main = { 1113 - $type: 'sh.tangled.repo.issue.state', 1114 - issue: issueUri, 1115 - state: 1116 - state === 'closed' 1117 - ? 'sh.tangled.repo.issue.state.closed' 1118 - : 'sh.tangled.repo.issue.state.open', 1119 - }; 1120 - 1121 - const result = await ok( 1122 - rpc.post('com.atproto.repo.createRecord', { 1123 - input: { 1124 - repo: agent.sub, 1125 - collection: ISSUE_STATE_COLLECTION, 1126 - record, 1127 - }, 1128 - }), 1129 - ); 1130 - 1131 - return { 1132 - uri: result.uri, 1133 - rkey: parseAtUri(result.uri).rkey, 1134 - }; 1135 - }; 1136 - 1137 - export const createPull = async ( 1138 - agent: OAuthUserAgent, 1139 - repo: RepoContext, 1140 - input: CreatePullInput, 1141 - ): Promise<RecordCreationResult> => { 1142 - const rpc = createAuthRpc(agent); 1143 - const patchBytes = new Uint8Array(gzipSync(strToU8(input.patch))); 1144 - const patchBlob = new Blob([patchBytes.buffer], { 1145 - type: 'application/gzip', 1146 - }); 1147 - 1148 - const uploaded = await ok( 1149 - rpc.post('com.atproto.repo.uploadBlob', { 1150 - input: patchBlob, 1151 - }), 1152 - ); 1153 - 1154 - const record: ShTangledRepoPull.Main = { 1155 - $type: 'sh.tangled.repo.pull', 1156 - title: input.title.trim(), 1157 - body: optionalString(input.body), 1158 - target: { 1159 - repo: repo.repoDid, 1160 - branch: input.targetBranch, 1161 - }, 1162 - source: { 1163 - branch: input.sourceBranch, 1164 - }, 1165 - rounds: [ 1166 - { 1167 - createdAt: new Date().toISOString(), 1168 - patchBlob: uploaded.blob, 1169 - }, 1170 - ], 1171 - createdAt: new Date().toISOString(), 1172 - mentions: extractDids(input.body), 1173 - references: extractAtUris(input.body), 1174 - }; 1175 - 1176 - const result = await ok( 1177 - rpc.post('com.atproto.repo.createRecord', { 1178 - input: { 1179 - repo: agent.sub, 1180 - collection: PULL_COLLECTION, 1181 - record, 1182 - }, 1183 - }), 1184 - ); 1185 - 1186 - return { 1187 - uri: result.uri, 1188 - rkey: parseAtUri(result.uri).rkey, 1189 - }; 1190 - }; 1191 - 1192 - export const createPullComment = async ( 1193 - agent: OAuthUserAgent, 1194 - pullUri: ResourceUri, 1195 - body: string, 1196 - ): Promise<RecordCreationResult> => { 1197 - const rpc = createAuthRpc(agent); 1198 - const record: ShTangledRepoPullComment.Main = { 1199 - $type: 'sh.tangled.repo.pull.comment', 1200 - pull: pullUri, 1201 - body: body.trim(), 1202 - createdAt: new Date().toISOString(), 1203 - mentions: extractDids(body), 1204 - references: extractAtUris(body), 1205 - }; 1206 - 1207 - const result = await ok( 1208 - rpc.post('com.atproto.repo.createRecord', { 1209 - input: { 1210 - repo: agent.sub, 1211 - collection: PULL_COMMENT_COLLECTION, 1212 - record, 1213 - }, 1214 - }), 1215 - ); 1216 - 1217 - return { 1218 - uri: result.uri, 1219 - rkey: parseAtUri(result.uri).rkey, 1220 - }; 1221 - }; 1222 - 1223 - export const setPullStatus = async ( 1224 - agent: OAuthUserAgent, 1225 - pullUri: ResourceUri, 1226 - status: 'open' | 'closed' | 'merged', 1227 - ): Promise<RecordCreationResult> => { 1228 - const rpc = createAuthRpc(agent); 1229 - const record: ShTangledRepoPullStatus.Main = { 1230 - $type: 'sh.tangled.repo.pull.status', 1231 - pull: pullUri, 1232 - status: 1233 - status === 'merged' 1234 - ? 'sh.tangled.repo.pull.status.merged' 1235 - : status === 'closed' 1236 - ? 'sh.tangled.repo.pull.status.closed' 1237 - : 'sh.tangled.repo.pull.status.open', 1238 - }; 1239 - 1240 - const result = await ok( 1241 - rpc.post('com.atproto.repo.createRecord', { 1242 - input: { 1243 - repo: agent.sub, 1244 - collection: PULL_STATUS_COLLECTION, 1245 - record, 1246 - }, 1247 - }), 1248 - ); 1249 - 1250 - return { 1251 - uri: result.uri, 1252 - rkey: parseAtUri(result.uri).rkey, 1253 - }; 1254 - }; 1255 - 1256 - export const decodeBlobText = (blob: BlobResponse): string => { 1257 - if (!blob.content) { 1258 - return ''; 1259 - } 1260 - 1261 - if (blob.encoding === 'base64') { 1262 - const buffer = Uint8Array.from(atob(blob.content), (char) => char.charCodeAt(0)); 1263 - return new TextDecoder().decode(buffer); 1264 - } 1265 - 1266 - return blob.content; 1267 - }; 1268 - 1269 - export const buildBlobDataUrl = (blob: BlobResponse): string | null => { 1270 - if (!blob.content || !blob.mimeType) { 1271 - return null; 1272 - } 1273 - 1274 - if (blob.encoding === 'base64') { 1275 - return `data:${blob.mimeType};base64,${blob.content}`; 1276 - } 1277 - 1278 - return `data:${blob.mimeType};charset=utf-8,${encodeURIComponent(blob.content)}`; 1279 - }; 1280 - 1281 - export const resolveAvatarUrl = async (identifier: string): Promise<string | null> => { 1282 - const actor = await resolveActor(identifier); 1283 - const tangledProfile = await getOptionalRecord<ShTangledActorProfile.Main>( 1284 - actor, 1285 - ACTOR_PROFILE_COLLECTION, 1286 - 'self', 1287 - ); 1288 - const tangledCid = extractBlobCid(tangledProfile?.avatar); 1289 - if (tangledCid) { 1290 - return buildPdsBlobUrl(actor.pds, actor.did, tangledCid); 1291 - } 1292 - 1293 - const bskyProfile = await getOptionalRecord<ProfileWithAvatar>( 1294 - actor, 1295 - APP_BSKY_PROFILE_COLLECTION, 1296 - 'self', 1297 - ); 1298 - const bskyCid = extractBlobCid(bskyProfile?.avatar); 1299 - if (bskyCid) { 1300 - return buildPdsBlobUrl(actor.pds, actor.did, bskyCid); 1301 - } 1302 - 1303 - return null; 1304 - }; 1305 - 1306 - export const parseAtUri = (uri: string): { did: Did; collection: Nsid; rkey: string } => { 1307 - if (!uri.startsWith('at://')) { 1308 - throw new Error(`Invalid at-uri: ${uri}`); 1309 - } 1310 - 1311 - const parts = uri.slice(5).split('/'); 1312 - const [did, collection, ...rkey] = parts; 1313 - return { 1314 - did: asDid(did), 1315 - collection: asNsid(collection), 1316 - rkey: rkey.join('/'), 1317 - }; 1318 - }; 1319 - 1320 - export const extractBlobCid = (blob: unknown): string | null => { 1321 - if (!blob || typeof blob !== 'object') { 1322 - return null; 1323 - } 1324 - 1325 - const value = blob as Record<string, unknown>; 1326 - const ref = value.ref; 1327 - 1328 - if (typeof ref === 'string') { 1329 - return ref; 1330 - } 1331 - 1332 - if (ref && typeof ref === 'object') { 1333 - const maybeLink = (ref as Record<string, unknown>).$link; 1334 - if (typeof maybeLink === 'string') { 1335 - return maybeLink; 1336 - } 1337 - } 1338 - 1339 - const maybeCid = value.cid; 1340 - return typeof maybeCid === 'string' ? maybeCid : null; 1341 - }; 1342 - 1343 - const findNumberedRecord = <T extends { number: number; rkey: string; uri: string }>( 1344 - records: T[], 1345 - ref: string, 1346 - ): T | undefined => { 1347 - const trimmed = ref.trim(); 1348 - if (/^\d+$/.test(trimmed)) { 1349 - const number = Number(trimmed); 1350 - return records.find((record) => record.number === number); 1351 - } 1352 - 1353 - return records.find( 1354 - (record) => 1355 - record.rkey === trimmed || 1356 - record.uri === trimmed || 1357 - parseAtUri(record.uri).rkey === trimmed, 1358 - ); 1359 - }; 1360 - 1361 - const optionalString = (value: string): string | undefined => { 1362 - const trimmed = value.trim(); 1363 - return trimmed.length > 0 ? trimmed : undefined; 1364 - }; 1365 - 1366 - const uniq = <T extends string>(values: T[]): T[] | undefined => { 1367 - if (values.length === 0) { 1368 - return undefined; 1369 - } 1370 - 1371 - return [...new Set(values)]; 1372 - }; 1373 - 1374 - const extractDids = (input: string): Did[] | undefined => 1375 - uniq( 1376 - Array.from( 1377 - input.matchAll(/\bdid:[a-z0-9]+:[A-Za-z0-9._:%-]+\b/gi), 1378 - (match) => match[0] as Did, 1379 - ), 1380 - ); 1381 - 1382 - const extractAtUris = (input: string): ResourceUri[] | undefined => 1383 - uniq( 1384 - Array.from( 1385 - input.matchAll(/\bat:\/\/[^\s<>()]+/gi), 1386 - (match) => match[0] as ResourceUri, 1387 - ), 1388 - ); 1389 - 1390 - const sortByCreatedAt = <T extends { value: { createdAt?: string } }>(left: T, right: T): number => 1391 - new Date(left.value.createdAt ?? 0).getTime() - new Date(right.value.createdAt ?? 0).getTime(); 1392 - 1393 - const asActorIdentifier = (value: string): ActorIdentifier => value as ActorIdentifier; 1394 - 1395 - const asDid = (value: string): Did => { 1396 - if (!value.startsWith('did:')) { 1397 - throw new Error(`Invalid DID: ${value}`); 1398 - } 1399 - 1400 - return value as Did; 1401 - }; 1402 - 1403 - const asNsid = (value: string): Nsid => { 1404 - if (value.split('.').length < 3) { 1405 - throw new Error(`Invalid NSID: ${value}`); 1406 - } 1407 - 1408 - return value as Nsid; 1409 - }; 1410 - 1411 - const buildPdsBlobUrl = (pds: string, did: Did, cid: string): string => { 1412 - const url = new URL('/xrpc/com.atproto.sync.getBlob', normalizeServiceUrl(pds)); 1413 - url.searchParams.set('did', did); 1414 - url.searchParams.set('cid', cid); 1415 - return url.toString(); 1416 - }; 56 + export type { 57 + HydratedRecord, 58 + PaginatedResult, 59 + RecordCreationResult, 60 + } from './api/records'; 61 + export type { 62 + BlobResponse, 63 + BranchEntry, 64 + BranchResponse, 65 + CommitHeadline, 66 + ComparePatch, 67 + CompareResponse, 68 + DefaultBranchResponse, 69 + LanguageResponse, 70 + LogResponse, 71 + RepoContext, 72 + RepoRecord, 73 + TagResponse, 74 + TreeEntry, 75 + TreeResponse, 76 + } from './api/repos'; 77 + export type { 78 + RepoStarSummary, 79 + } from './api/stars'; 80 + export type { 81 + CreateIssueInput, 82 + IssueComment, 83 + IssueDetail, 84 + IssueSummary, 85 + } from './api/issues'; 86 + export type { 87 + CreatePullInput, 88 + PullComment, 89 + PullDetail, 90 + PullSummary, 91 + } from './api/pulls';
+7
src/lib/api/constants.ts
··· 1 + export { 2 + CONSTELLATION_SERVICE, 3 + PUBLIC_API_SERVICE, 4 + SLINGSHOT_SERVICE, 5 + SPACEDUST_SERVICE, 6 + TANGLED_OAUTH_SCOPE, 7 + } from './core';
+1416
src/lib/api/core.ts
··· 1 + import { Client, ClientResponseError, ok, simpleFetchHandler } from '@atcute/client'; 2 + import { 3 + type ActorResolver, 4 + type ResolveActorOptions, 5 + type ResolvedActor, 6 + } from '@atcute/identity-resolver'; 7 + import type { 8 + ActorIdentifier, 9 + Did, 10 + Nsid, 11 + ResourceUri, 12 + } from '@atcute/lexicons/syntax'; 13 + import type { GenericUri } from '@atcute/lexicons/syntax'; 14 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 15 + import { 16 + ShTangledActorProfile, 17 + ShTangledFeedStar, 18 + ShTangledRepo, 19 + ShTangledRepoIssue, 20 + ShTangledRepoIssueComment, 21 + ShTangledRepoIssueState, 22 + ShTangledRepoPull, 23 + ShTangledRepoPullComment, 24 + ShTangledRepoPullStatus, 25 + } from '@atcute/tangled'; 26 + import { gunzipSync, gzipSync, strFromU8, strToU8 } from 'fflate'; 27 + 28 + import type {} from '@atcute/atproto'; 29 + import type {} from '@atcute/microcosm'; 30 + import type {} from '@atcute/tangled'; 31 + 32 + export const PUBLIC_API_SERVICE = 'https://public.api.bsky.app'; 33 + export const CONSTELLATION_SERVICE = 'https://constellation.microcosm.blue'; 34 + export const SLINGSHOT_SERVICE = 'https://slingshot.microcosm.blue'; 35 + export const SPACEDUST_SERVICE = 'wss://spacedust.microcosm.blue/subscribe'; 36 + 37 + export const TANGLED_OAUTH_SCOPE = import.meta.env.VITE_OAUTH_SCOPE; 38 + 39 + const REPO_COLLECTION: Nsid = 'sh.tangled.repo'; 40 + const STAR_COLLECTION: Nsid = 'sh.tangled.feed.star'; 41 + const ACTOR_PROFILE_COLLECTION: Nsid = 'sh.tangled.actor.profile'; 42 + const APP_BSKY_PROFILE_COLLECTION: Nsid = 'app.bsky.actor.profile'; 43 + const ISSUE_COLLECTION: Nsid = 'sh.tangled.repo.issue'; 44 + const ISSUE_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.issue.comment'; 45 + const ISSUE_STATE_COLLECTION: Nsid = 'sh.tangled.repo.issue.state'; 46 + const PULL_COLLECTION: Nsid = 'sh.tangled.repo.pull'; 47 + const PULL_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.pull.comment'; 48 + const PULL_STATUS_COLLECTION: Nsid = 'sh.tangled.repo.pull.status'; 49 + 50 + class SlingshotActorResolver implements ActorResolver { 51 + async resolve(actor: ActorIdentifier, options?: ResolveActorOptions): Promise<ResolvedActor> { 52 + const resolved = await ok( 53 + getRpc(SLINGSHOT_SERVICE).get('blue.microcosm.identity.resolveMiniDoc', { 54 + params: { 55 + identifier: actor, 56 + }, 57 + signal: options?.signal, 58 + }), 59 + ); 60 + 61 + return { 62 + did: resolved.did, 63 + handle: resolved.handle, 64 + pds: normalizeServiceUrl(resolved.pds), 65 + }; 66 + } 67 + } 68 + 69 + export const identityResolver = new SlingshotActorResolver(); 70 + 71 + export interface RepoContext { 72 + owner: ResolvedActor; 73 + record: RepoRecord; 74 + repoDid: Did; 75 + slug: string; 76 + rkey: string; 77 + knot: string; 78 + } 79 + 80 + export interface RepoRecord { 81 + uri: ResourceUri; 82 + cid: string; 83 + rkey: string; 84 + value: ShTangledRepo.Main; 85 + } 86 + 87 + export interface HydratedRecord<T> { 88 + author: ResolvedActor; 89 + collection: Nsid; 90 + rkey: string; 91 + uri: ResourceUri; 92 + cid?: string; 93 + value: T; 94 + } 95 + 96 + export interface IssueSummary extends HydratedRecord<ShTangledRepoIssue.Main> { 97 + number: number; 98 + state: 'open' | 'closed'; 99 + } 100 + 101 + export interface PullSummary extends HydratedRecord<ShTangledRepoPull.Main> { 102 + number: number; 103 + state: 'open' | 'closed' | 'merged'; 104 + } 105 + 106 + export interface IssueComment extends HydratedRecord<ShTangledRepoIssueComment.Main> {} 107 + export interface PullComment extends HydratedRecord<ShTangledRepoPullComment.Main> {} 108 + 109 + export interface IssueDetail { 110 + issue: IssueSummary; 111 + comments: IssueComment[]; 112 + } 113 + 114 + export interface PullDetail { 115 + pull: PullSummary; 116 + comments: PullComment[]; 117 + } 118 + 119 + export interface PaginatedResult<T> { 120 + items: T[]; 121 + totalCount?: number; 122 + hasNext: boolean; 123 + } 124 + 125 + export interface TreeResponse { 126 + ref: string; 127 + parent?: string; 128 + dotdot?: string; 129 + lastCommit?: CommitHeadline; 130 + readme?: { 131 + contents: string; 132 + filename: string; 133 + }; 134 + files: TreeEntry[]; 135 + } 136 + 137 + export interface TreeEntry { 138 + name: string; 139 + mode: string; 140 + size: number; 141 + last_commit?: CommitHeadline; 142 + } 143 + 144 + export interface CommitHeadline { 145 + hash: string; 146 + message: string; 147 + when: string; 148 + author?: { 149 + email: string; 150 + name: string; 151 + when: string; 152 + }; 153 + } 154 + 155 + export interface BranchResponse { 156 + branches: BranchEntry[]; 157 + } 158 + 159 + export interface DefaultBranchResponse { 160 + name: string; 161 + hash?: string; 162 + when?: string; 163 + } 164 + 165 + export interface BranchEntry { 166 + reference: { 167 + name: string; 168 + hash: string; 169 + }; 170 + commit?: { 171 + Message: string; 172 + Author?: { 173 + Name: string; 174 + Email: string; 175 + When: string; 176 + }; 177 + Committer?: { 178 + Name: string; 179 + Email: string; 180 + When: string; 181 + }; 182 + }; 183 + is_default?: boolean; 184 + } 185 + 186 + export interface TagResponse { 187 + tags: Array<{ 188 + name: string; 189 + hash: string; 190 + message?: string; 191 + tag?: { 192 + Name: string; 193 + Message: string; 194 + Tagger?: { 195 + Name: string; 196 + Email: string; 197 + When: string; 198 + }; 199 + }; 200 + }>; 201 + } 202 + 203 + export interface LanguageResponse { 204 + ref: string; 205 + totalFiles?: number; 206 + totalSize?: number; 207 + languages: Array<{ 208 + name: string; 209 + percentage: number; 210 + size: number; 211 + color?: string; 212 + }>; 213 + } 214 + 215 + export interface LogResponse { 216 + ref: string; 217 + total: number; 218 + page: number; 219 + per_page: number; 220 + log: boolean; 221 + commits: Array<{ 222 + this: string; 223 + message: string; 224 + author?: { 225 + Name: string; 226 + Email: string; 227 + When: string; 228 + }; 229 + committer?: { 230 + Name: string; 231 + Email: string; 232 + When: string; 233 + }; 234 + pgp_signature?: string; 235 + parent?: string; 236 + parent_hashes?: number[][]; 237 + }>; 238 + } 239 + 240 + export interface BlobResponse { 241 + path: string; 242 + ref: string; 243 + content?: string; 244 + encoding?: 'base64' | 'utf-8'; 245 + fileTooLarge?: boolean; 246 + isBinary?: boolean; 247 + mimeType?: string; 248 + size?: number; 249 + lastCommit?: CommitHeadline; 250 + submodule?: { 251 + name: string; 252 + url: string; 253 + branch?: string; 254 + }; 255 + } 256 + 257 + export interface ComparePatch { 258 + Title: string; 259 + Body: string; 260 + Raw: string; 261 + SHA: string; 262 + Author?: string; 263 + Committer?: string; 264 + AuthorDate?: string; 265 + CommitterDate?: string; 266 + SubjectPrefix?: string; 267 + RawHeaders?: string; 268 + BodyAppendix?: string; 269 + } 270 + 271 + export interface CompareResponse { 272 + rev1: string; 273 + rev2: string; 274 + patch: string; 275 + format_patch: ComparePatch[]; 276 + } 277 + 278 + export interface CreateIssueInput { 279 + title: string; 280 + body: string; 281 + } 282 + 283 + export interface CreatePullInput { 284 + title: string; 285 + body: string; 286 + targetBranch: string; 287 + sourceBranch: string; 288 + patch: string; 289 + } 290 + 291 + export interface RecordCreationResult { 292 + uri: ResourceUri; 293 + rkey: string; 294 + } 295 + 296 + interface BacklinkRecordRef { 297 + did: Did; 298 + collection: Nsid; 299 + rkey: string; 300 + } 301 + 302 + interface ProfileWithAvatar { 303 + avatar?: unknown; 304 + } 305 + 306 + const actorCache = new Map<string, Promise<ResolvedActor>>(); 307 + const rpcCache = new Map<string, Client>(); 308 + 309 + const normalizeServiceUrl = (input: string): string => { 310 + if (input.startsWith('http://') || input.startsWith('https://')) { 311 + return input; 312 + } 313 + 314 + return `https://${input}`; 315 + }; 316 + 317 + const getRpc = (service: string): Client => { 318 + const normalized = normalizeServiceUrl(service); 319 + const existing = rpcCache.get(normalized); 320 + if (existing) { 321 + return existing; 322 + } 323 + 324 + const client = new Client({ 325 + handler: simpleFetchHandler({ 326 + service: normalized, 327 + }), 328 + }); 329 + 330 + rpcCache.set(normalized, client); 331 + return client; 332 + }; 333 + 334 + export const createAuthRpc = (agent: OAuthUserAgent): Client => 335 + new Client({ 336 + handler: agent, 337 + }); 338 + 339 + export const resolveActor = async (identifier: string): Promise<ResolvedActor> => { 340 + const key = identifier.toLowerCase(); 341 + const cached = actorCache.get(key); 342 + if (cached) { 343 + return cached; 344 + } 345 + 346 + const promise = identityResolver.resolve(asActorIdentifier(identifier)); 347 + actorCache.set(key, promise); 348 + return promise; 349 + }; 350 + 351 + const fetchAllRepoRecords = async ( 352 + actor: ResolvedActor, 353 + collection: Nsid, 354 + ): Promise<Array<{ uri: ResourceUri; cid: string; value: unknown }>> => { 355 + const rpc = getRpc(actor.pds); 356 + const records: Array<{ uri: ResourceUri; cid: string; value: unknown }> = []; 357 + let cursor: string | undefined; 358 + 359 + do { 360 + const page = await ok( 361 + rpc.get('com.atproto.repo.listRecords', { 362 + params: { 363 + repo: actor.did, 364 + collection, 365 + limit: 100, 366 + cursor, 367 + reverse: true, 368 + }, 369 + }), 370 + ); 371 + 372 + records.push(...page.records); 373 + cursor = page.cursor; 374 + } while (cursor); 375 + 376 + return records; 377 + }; 378 + 379 + export const listRepoRecords = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => { 380 + const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; 381 + const records = await fetchAllRepoRecords(actor, REPO_COLLECTION); 382 + 383 + return records.map((record) => ({ 384 + uri: record.uri, 385 + cid: record.cid, 386 + rkey: parseAtUri(record.uri).rkey, 387 + value: record.value as ShTangledRepo.Main, 388 + })); 389 + }; 390 + 391 + export interface RepoStarSummary { 392 + count: number; 393 + isStarred: boolean; 394 + currentUserStarRkey?: string; 395 + } 396 + 397 + const STAR_BACKLINK_SOURCES = [ 398 + 'sh.tangled.feed.star:subject.did', 399 + 'sh.tangled.feed.star:subjectDid', 400 + ]; 401 + 402 + export const getRepoStarSummary = async (repo: RepoContext, viewerDid?: Did | null): Promise<RepoStarSummary> => { 403 + const starrers = new Set<Did>(); 404 + const backlinkDidGroups = await Promise.all(STAR_BACKLINK_SOURCES.map((source) => getBacklinkDids(repo.repoDid, source))); 405 + 406 + for (const did of backlinkDidGroups.flat()) { 407 + starrers.add(did); 408 + } 409 + 410 + const currentUserStar = 411 + viewerDid && starrers.has(viewerDid) 412 + ? await findBacklinkRefByDid(repo.repoDid, STAR_BACKLINK_SOURCES, viewerDid) 413 + : undefined; 414 + 415 + return { 416 + count: starrers.size, 417 + isStarred: !!currentUserStar, 418 + currentUserStarRkey: currentUserStar?.rkey, 419 + }; 420 + }; 421 + 422 + export const getRepoStarCount = async (repo: RepoContext): Promise<number> => { 423 + const summary = await getRepoStarSummary(repo); 424 + return summary.count; 425 + }; 426 + 427 + export const createRepoStar = async (agent: OAuthUserAgent, repo: RepoContext): Promise<RecordCreationResult> => { 428 + const rpc = createAuthRpc(agent); 429 + const record: ShTangledFeedStar.Main = { 430 + $type: 'sh.tangled.feed.star', 431 + subject: { 432 + $type: 'sh.tangled.feed.star#repo', 433 + did: repo.repoDid, 434 + }, 435 + createdAt: new Date().toISOString(), 436 + }; 437 + const result = await ok( 438 + rpc.post('com.atproto.repo.createRecord', { 439 + input: { 440 + repo: agent.sub, 441 + collection: STAR_COLLECTION, 442 + record, 443 + }, 444 + }), 445 + ); 446 + 447 + return { 448 + uri: result.uri, 449 + rkey: parseAtUri(result.uri).rkey, 450 + }; 451 + }; 452 + 453 + export const deleteRepoStar = async (agent: OAuthUserAgent, rkey: string): Promise<void> => { 454 + const rpc = createAuthRpc(agent); 455 + await ok( 456 + rpc.post('com.atproto.repo.deleteRecord', { 457 + input: { 458 + repo: agent.sub, 459 + collection: STAR_COLLECTION, 460 + rkey, 461 + }, 462 + }), 463 + ); 464 + }; 465 + 466 + export const getRepo = async (ownerIdentifier: string, repoSlug: string): Promise<RepoContext> => { 467 + const owner = await resolveActor(ownerIdentifier); 468 + const all = await listRepoRecords(owner); 469 + const wanted = repoSlug.trim().toLowerCase(); 470 + const match = all.find((record) => { 471 + const name = record.value.name?.trim().toLowerCase(); 472 + return name === wanted || record.rkey.toLowerCase() === wanted; 473 + }); 474 + 475 + if (!match) { 476 + throw new Error(`Repository not found: ${ownerIdentifier}/${repoSlug}`); 477 + } 478 + 479 + if (!match.value.repoDid) { 480 + throw new Error(`Repository is missing repoDid metadata`); 481 + } 482 + 483 + const slug = match.value.name?.trim() || match.rkey; 484 + 485 + return { 486 + owner, 487 + record: match, 488 + repoDid: match.value.repoDid, 489 + slug, 490 + rkey: match.rkey, 491 + knot: normalizeServiceUrl(match.value.knot), 492 + }; 493 + }; 494 + 495 + export const getRepoTree = async ( 496 + repo: RepoContext, 497 + ref: string, 498 + path = '', 499 + ): Promise<TreeResponse> => 500 + (await ok( 501 + getRpc(repo.knot).get('sh.tangled.repo.tree', { 502 + params: { 503 + repo: repo.repoDid, 504 + ref, 505 + path, 506 + }, 507 + }), 508 + )) as TreeResponse; 509 + 510 + export const getRepoBranches = async (repo: RepoContext): Promise<BranchResponse> => 511 + (await ok( 512 + getRpc(repo.knot).get('sh.tangled.repo.branches', { 513 + params: { 514 + repo: repo.repoDid, 515 + }, 516 + as: 'json', 517 + }), 518 + )) as BranchResponse; 519 + 520 + export const getRepoDefaultBranch = async (repo: RepoContext): Promise<DefaultBranchResponse> => 521 + (await ok( 522 + getRpc(repo.knot).get('sh.tangled.repo.getDefaultBranch', { 523 + params: { 524 + repo: repo.repoDid, 525 + }, 526 + }), 527 + )) as DefaultBranchResponse; 528 + 529 + export const getRepoTags = async (repo: RepoContext): Promise<TagResponse> => 530 + (await ok( 531 + getRpc(repo.knot).get('sh.tangled.repo.tags', { 532 + params: { 533 + repo: repo.repoDid, 534 + }, 535 + as: 'json', 536 + }), 537 + )) as TagResponse; 538 + 539 + export const getRepoLanguages = async (repo: RepoContext, ref: string): Promise<LanguageResponse> => 540 + (await ok( 541 + getRpc(repo.knot).get('sh.tangled.repo.languages', { 542 + params: { 543 + repo: repo.repoDid, 544 + ref, 545 + }, 546 + }), 547 + )) as LanguageResponse; 548 + 549 + export const getRepoLog = async (repo: RepoContext, ref: string): Promise<LogResponse> => 550 + (await ok( 551 + getRpc(repo.knot).get('sh.tangled.repo.log', { 552 + params: { 553 + repo: repo.repoDid, 554 + ref, 555 + limit: 8, 556 + }, 557 + as: 'json', 558 + }), 559 + )) as LogResponse; 560 + 561 + export const getRepoBlob = async ( 562 + repo: RepoContext, 563 + ref: string, 564 + path: string, 565 + ): Promise<BlobResponse> => 566 + (await ok( 567 + getRpc(repo.knot).get('sh.tangled.repo.blob', { 568 + params: { 569 + repo: repo.repoDid, 570 + ref, 571 + path, 572 + }, 573 + }), 574 + )) as BlobResponse; 575 + 576 + export const compareBranches = async ( 577 + repo: RepoContext, 578 + rev1: string, 579 + rev2: string, 580 + ): Promise<CompareResponse> => 581 + (await ok( 582 + getRpc(repo.knot).get('sh.tangled.repo.compare', { 583 + params: { 584 + repo: repo.repoDid, 585 + rev1, 586 + rev2, 587 + }, 588 + as: 'json', 589 + }), 590 + )) as CompareResponse; 591 + 592 + const getBacklinksPage = async ( 593 + subject: GenericUri, 594 + source: string, 595 + limit: number, 596 + cursor?: string, 597 + ): Promise<{ records: BacklinkRecordRef[]; cursor?: string; total?: number }> => { 598 + const constellation = getRpc(CONSTELLATION_SERVICE); 599 + const page = await ok( 600 + constellation.get('blue.microcosm.links.getBacklinks', { 601 + params: { 602 + subject, 603 + source, 604 + limit, 605 + ...(cursor ? { cursor } : {}), 606 + }, 607 + }), 608 + ); 609 + 610 + return page as { records: BacklinkRecordRef[]; cursor?: string; total?: number }; 611 + }; 612 + 613 + const getBacklinkDidsPage = async ( 614 + subject: GenericUri, 615 + source: string, 616 + limit: number, 617 + cursor?: string, 618 + ): Promise<{ linking_dids: Did[]; cursor?: string | null; total?: number }> => { 619 + const constellation = getRpc(CONSTELLATION_SERVICE); 620 + const page = await ok( 621 + constellation.get('blue.microcosm.links.getBacklinkDids', { 622 + params: { 623 + subject, 624 + source, 625 + limit, 626 + ...(cursor ? { cursor } : {}), 627 + }, 628 + }), 629 + ); 630 + 631 + return page as { linking_dids: Did[]; cursor?: string | null; total?: number }; 632 + }; 633 + 634 + const getBacklinkDids = async (subject: GenericUri, source: string): Promise<Did[]> => { 635 + const dids: Did[] = []; 636 + let cursor: string | null = null; 637 + 638 + do { 639 + const page = await getBacklinkDidsPage(subject, source, 100, cursor ?? undefined); 640 + dids.push(...page.linking_dids); 641 + cursor = page.cursor ?? null; 642 + } while (cursor); 643 + 644 + return dids; 645 + }; 646 + 647 + const findBacklinkRefByDidForSource = async ( 648 + subject: GenericUri, 649 + source: string, 650 + did: Did, 651 + ): Promise<BacklinkRecordRef | undefined> => { 652 + let cursor: string | null = null; 653 + 654 + do { 655 + const page = await getBacklinksPage(subject, source, 100, cursor ?? undefined); 656 + const ref = page.records.find((record) => record.did === did && record.collection === STAR_COLLECTION); 657 + if (ref) { 658 + return ref; 659 + } 660 + cursor = page.cursor ?? null; 661 + } while (cursor); 662 + }; 663 + 664 + const findBacklinkRefByDid = async ( 665 + subject: GenericUri, 666 + sources: string[], 667 + did: Did, 668 + ): Promise<BacklinkRecordRef | undefined> => { 669 + const refs = await Promise.all(sources.map((source) => findBacklinkRefByDidForSource(subject, source, did))); 670 + return refs.find((ref) => ref); 671 + }; 672 + 673 + const getBacklinks = async (subject: GenericUri, source: string): Promise<BacklinkRecordRef[]> => { 674 + const records: BacklinkRecordRef[] = []; 675 + let cursor: string | null = null; 676 + const constellation = getRpc(CONSTELLATION_SERVICE); 677 + 678 + do { 679 + const page: { records: BacklinkRecordRef[]; cursor?: string | null } = await ok( 680 + constellation.get('blue.microcosm.links.getBacklinks', { 681 + params: { 682 + subject, 683 + source, 684 + limit: 100, 685 + ...(cursor ? { cursor } : {}), 686 + }, 687 + }), 688 + ); 689 + records.push(...page.records); 690 + cursor = page.cursor ?? null; 691 + } while (cursor); 692 + 693 + return records; 694 + }; 695 + 696 + const hydrateRecord = async <T>( 697 + ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, 698 + ): Promise<HydratedRecord<T>> => { 699 + const actor = await resolveActor(ref.did); 700 + const record = await ok( 701 + getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { 702 + params: { 703 + repo: ref.did, 704 + collection: ref.collection, 705 + rkey: ref.rkey, 706 + }, 707 + }), 708 + ); 709 + 710 + return { 711 + author: actor, 712 + collection: ref.collection, 713 + rkey: ref.rkey, 714 + uri: record.uri, 715 + cid: record.cid, 716 + value: record.value as T, 717 + }; 718 + }; 719 + 720 + const fetchRecordValue = async <T>( 721 + ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, 722 + ): Promise<T> => { 723 + const record = await ok( 724 + getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { 725 + params: { 726 + repo: ref.did, 727 + collection: ref.collection, 728 + rkey: ref.rkey, 729 + }, 730 + }), 731 + ); 732 + 733 + return record.value as T; 734 + }; 735 + 736 + const hydrateBacklinks = async <T>(refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<T>>> => 737 + Promise.all(refs.map((ref) => hydrateRecord<T>(ref))); 738 + 739 + const getOptionalRecord = async <T>( 740 + actor: ResolvedActor, 741 + collection: Nsid, 742 + rkey: string, 743 + ): Promise<T | null> => { 744 + try { 745 + const record = await ok( 746 + getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { 747 + params: { 748 + repo: actor.did, 749 + collection, 750 + rkey, 751 + }, 752 + }), 753 + ); 754 + 755 + return record.value as T; 756 + } catch (cause) { 757 + if ( 758 + cause instanceof ClientResponseError && 759 + (cause.status === 400 || cause.status === 404) 760 + ) { 761 + return null; 762 + } 763 + 764 + throw cause; 765 + } 766 + }; 767 + 768 + const toNumberMap = <T extends { uri: string; rkey: string; value: { createdAt?: string } }>( 769 + items: T[], 770 + ): Map<string, number> => { 771 + const ordered = [...items].sort((left, right) => { 772 + const leftTime = left.value.createdAt ? new Date(left.value.createdAt).getTime() : 0; 773 + const rightTime = right.value.createdAt ? new Date(right.value.createdAt).getTime() : 0; 774 + if (leftTime !== rightTime) { 775 + return leftTime - rightTime; 776 + } 777 + 778 + return left.rkey.localeCompare(right.rkey); 779 + }); 780 + 781 + return new Map(ordered.map((item, index) => [item.uri, index + 1])); 782 + }; 783 + 784 + const normalizeIssueState = (value?: string): 'open' | 'closed' => 785 + value?.endsWith('.closed') ? 'closed' : 'open'; 786 + 787 + const normalizePullStatus = (value?: string): 'open' | 'closed' | 'merged' => { 788 + if (value?.endsWith('.merged')) { 789 + return 'merged'; 790 + } 791 + 792 + if (value?.endsWith('.closed')) { 793 + return 'closed'; 794 + } 795 + 796 + return 'open'; 797 + }; 798 + 799 + const latestBacklinkRefByRkey = (refs: BacklinkRecordRef[]): BacklinkRecordRef | undefined => 800 + [...refs].sort((left, right) => left.rkey.localeCompare(right.rkey)).at(-1); 801 + 802 + const BACKLINK_BATCH_LIMIT = 50; 803 + 804 + const listBacklinkedRecordsPage = async <T extends { createdAt?: string }, State extends string>( 805 + subject: GenericUri, 806 + source: string | string[], 807 + options: { offset: number; limit: number; state: State }, 808 + getState: (uri: ResourceUri) => Promise<State>, 809 + ): Promise<PaginatedResult<HydratedRecord<T> & { number: number; state: State }>> => { 810 + const requestedEnd = options.offset + options.limit; 811 + const items: Array<HydratedRecord<T> & { number: number; state: State }> = []; 812 + const sources = Array.isArray(source) ? source : [source]; 813 + const batchLimit = Math.min(BACKLINK_BATCH_LIMIT, Math.max(options.limit + 1, 1)); 814 + const cursors = new Map(sources.map((entry) => [entry, undefined as string | undefined])); 815 + const exhausted = new Set<string>(); 816 + const seenRefs = new Set<string>(); 817 + let matchingSeen = 0; 818 + let scannedSeen = 0; 819 + let total: number | undefined; 820 + 821 + while (items.length <= options.limit) { 822 + const pages = await Promise.all( 823 + sources 824 + .filter((entry) => !exhausted.has(entry)) 825 + .map(async (entry) => ({ 826 + source: entry, 827 + page: await getBacklinksPage(subject, entry, batchLimit, cursors.get(entry)), 828 + })), 829 + ); 830 + 831 + if (pages.length === 0) { 832 + return { items, totalCount: matchingSeen, hasNext: false }; 833 + } 834 + 835 + const refs = pages 836 + .flatMap(({ source: entry, page }) => { 837 + total = sources.length === 1 ? page.total ?? total : undefined; 838 + cursors.set(entry, page.cursor); 839 + if (!page.cursor) { 840 + exhausted.add(entry); 841 + } 842 + return page.records; 843 + }) 844 + .filter((ref) => { 845 + const key = `${ref.did}/${ref.collection}/${ref.rkey}`; 846 + if (seenRefs.has(key)) { 847 + return false; 848 + } 849 + seenRefs.add(key); 850 + return true; 851 + }); 852 + 853 + if (refs.length === 0) { 854 + if (exhausted.size === sources.length) { 855 + return { items, totalCount: matchingSeen, hasNext: false }; 856 + } 857 + continue; 858 + } 859 + 860 + if (pages.every(({ page }) => page.records.length === 0)) { 861 + return { items, totalCount: matchingSeen, hasNext: false }; 862 + } 863 + 864 + const hydrated = await hydrateBacklinks<T>(refs); 865 + hydrated.sort(sortByCreatedAt).reverse(); 866 + const states = await Promise.all(hydrated.map((record) => getState(record.uri))); 867 + 868 + for (let index = 0; index < hydrated.length; index += 1) { 869 + const record = hydrated[index]; 870 + const state = states[index]; 871 + const number = total ? Math.max(total - scannedSeen, 1) : scannedSeen + 1; 872 + scannedSeen += 1; 873 + 874 + if (state !== options.state) { 875 + continue; 876 + } 877 + 878 + if (matchingSeen >= options.offset && matchingSeen < requestedEnd + 1) { 879 + items.push({ ...record, number, state }); 880 + } 881 + 882 + matchingSeen += 1; 883 + if (matchingSeen > requestedEnd) { 884 + return { items: items.slice(0, options.limit), hasNext: true }; 885 + } 886 + } 887 + 888 + if (exhausted.size === sources.length) { 889 + return { items, totalCount: matchingSeen, hasNext: false }; 890 + } 891 + } 892 + 893 + return { items: items.slice(0, options.limit), hasNext: true }; 894 + }; 895 + 896 + const getLatestIssueState = async (issueUri: ResourceUri): Promise<'open' | 'closed'> => { 897 + const refs = await getBacklinks(issueUri, 'sh.tangled.repo.issue.state:issue'); 898 + if (refs.length === 0) { 899 + return 'open'; 900 + } 901 + 902 + const state = await fetchRecordValue<ShTangledRepoIssueState.Main>(latestBacklinkRefByRkey(refs)!); 903 + return normalizeIssueState(state.state); 904 + }; 905 + 906 + const getLatestPullStatus = async (pullUri: ResourceUri): Promise<'open' | 'closed' | 'merged'> => { 907 + const refs = await getBacklinks(pullUri, 'sh.tangled.repo.pull.status:pull'); 908 + if (refs.length === 0) { 909 + return 'open'; 910 + } 911 + 912 + const status = await fetchRecordValue<ShTangledRepoPullStatus.Main>(latestBacklinkRefByRkey(refs)!); 913 + return normalizePullStatus(status.status); 914 + }; 915 + 916 + export const listIssues = async (repo: RepoContext): Promise<IssueSummary[]> => { 917 + const refs = Array.from( 918 + new Map( 919 + ( 920 + await Promise.all([ 921 + getBacklinks(repo.repoDid, 'sh.tangled.repo.issue:repoDid'), 922 + getBacklinks(repo.repoDid, 'sh.tangled.repo.issue:repo'), 923 + ]) 924 + ) 925 + .flat() 926 + .map((ref) => [`${ref.did}/${ref.collection}/${ref.rkey}`, ref] as const), 927 + ).values(), 928 + ); 929 + const issues = await hydrateBacklinks<ShTangledRepoIssue.Main>(refs); 930 + const numbers = toNumberMap(issues); 931 + const states = await Promise.all(issues.map((issue) => getLatestIssueState(issue.uri))); 932 + 933 + return issues 934 + .map((issue, index) => ({ 935 + ...issue, 936 + number: numbers.get(issue.uri) ?? index + 1, 937 + state: states[index], 938 + })) 939 + .sort((left, right) => right.number - left.number); 940 + }; 941 + 942 + export const listIssuesPage = async ( 943 + repo: RepoContext, 944 + options: { offset: number; limit: number; state: 'open' | 'closed' }, 945 + ): Promise<PaginatedResult<IssueSummary>> => 946 + listBacklinkedRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 947 + repo.repoDid, 948 + ['sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo'], 949 + options, 950 + getLatestIssueState, 951 + ); 952 + 953 + export const getIssue = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { 954 + const issues = await listIssues(repo); 955 + const issue = findNumberedRecord(issues, issueRef); 956 + if (!issue) { 957 + throw new Error(`Issue not found`); 958 + } 959 + 960 + const commentRefs = await getBacklinks(issue.uri, 'sh.tangled.repo.issue.comment:issue'); 961 + const comments = await hydrateBacklinks<ShTangledRepoIssueComment.Main>(commentRefs); 962 + comments.sort(sortByCreatedAt); 963 + 964 + return { 965 + issue, 966 + comments, 967 + }; 968 + }; 969 + 970 + export const listPulls = async (repo: RepoContext): Promise<PullSummary[]> => { 971 + const refs = await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo'); 972 + const pulls = await hydrateBacklinks<ShTangledRepoPull.Main>(refs); 973 + const numbers = toNumberMap(pulls); 974 + const states = await Promise.all(pulls.map((pull) => getLatestPullStatus(pull.uri))); 975 + 976 + return pulls 977 + .map((pull, index) => ({ 978 + ...pull, 979 + number: numbers.get(pull.uri) ?? index + 1, 980 + state: states[index], 981 + })) 982 + .sort((left, right) => right.number - left.number); 983 + }; 984 + 985 + export const listPullsPage = async ( 986 + repo: RepoContext, 987 + options: { offset: number; limit: number; state: 'open' | 'closed' | 'merged' }, 988 + ): Promise<PaginatedResult<PullSummary>> => 989 + listBacklinkedRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 990 + repo.repoDid, 991 + 'sh.tangled.repo.pull:target.repo', 992 + options, 993 + getLatestPullStatus, 994 + ); 995 + 996 + export const getPull = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { 997 + const pulls = await listPulls(repo); 998 + const pull = findNumberedRecord(pulls, pullRef); 999 + if (!pull) { 1000 + throw new Error(`Pull request not found`); 1001 + } 1002 + 1003 + const commentRefs = await getBacklinks(pull.uri, 'sh.tangled.repo.pull.comment:pull'); 1004 + const comments = await hydrateBacklinks<ShTangledRepoPullComment.Main>(commentRefs); 1005 + comments.sort(sortByCreatedAt); 1006 + 1007 + return { 1008 + pull, 1009 + comments, 1010 + }; 1011 + }; 1012 + 1013 + export const fetchPullRoundPatch = async ( 1014 + pull: PullSummary, 1015 + roundIndex: number, 1016 + ): Promise<string> => { 1017 + const round = pull.value.rounds[roundIndex]; 1018 + if (!round) { 1019 + throw new Error(`Round ${roundIndex + 1} not found`); 1020 + } 1021 + 1022 + const cid = extractBlobCid(round.patchBlob); 1023 + if (!cid) { 1024 + throw new Error(`Missing patch blob CID`); 1025 + } 1026 + 1027 + const rpc = getRpc(pull.author.pds); 1028 + const bytes = await ok( 1029 + rpc.get('com.atproto.sync.getBlob', { 1030 + params: { 1031 + did: pull.author.did, 1032 + cid, 1033 + }, 1034 + as: 'bytes', 1035 + }), 1036 + ); 1037 + 1038 + return strFromU8(gunzipSync(bytes)); 1039 + }; 1040 + 1041 + export const createIssue = async ( 1042 + agent: OAuthUserAgent, 1043 + repo: RepoContext, 1044 + input: CreateIssueInput, 1045 + ): Promise<RecordCreationResult> => { 1046 + const rpc = createAuthRpc(agent); 1047 + const record: ShTangledRepoIssue.Main = { 1048 + $type: 'sh.tangled.repo.issue', 1049 + repo: repo.repoDid, 1050 + title: input.title.trim(), 1051 + body: optionalString(input.body), 1052 + createdAt: new Date().toISOString(), 1053 + mentions: extractDids(input.body), 1054 + references: extractAtUris(input.body), 1055 + }; 1056 + 1057 + const result = await ok( 1058 + rpc.post('com.atproto.repo.createRecord', { 1059 + input: { 1060 + repo: agent.sub, 1061 + collection: ISSUE_COLLECTION, 1062 + record, 1063 + }, 1064 + }), 1065 + ); 1066 + 1067 + return { 1068 + uri: result.uri, 1069 + rkey: parseAtUri(result.uri).rkey, 1070 + }; 1071 + }; 1072 + 1073 + export const createIssueComment = async ( 1074 + agent: OAuthUserAgent, 1075 + issueUri: ResourceUri, 1076 + body: string, 1077 + replyTo?: ResourceUri, 1078 + ): Promise<RecordCreationResult> => { 1079 + const rpc = createAuthRpc(agent); 1080 + const record: ShTangledRepoIssueComment.Main = { 1081 + $type: 'sh.tangled.repo.issue.comment', 1082 + issue: issueUri, 1083 + body: body.trim(), 1084 + createdAt: new Date().toISOString(), 1085 + mentions: extractDids(body), 1086 + references: extractAtUris(body), 1087 + ...(replyTo ? { replyTo } : {}), 1088 + }; 1089 + 1090 + const result = await ok( 1091 + rpc.post('com.atproto.repo.createRecord', { 1092 + input: { 1093 + repo: agent.sub, 1094 + collection: ISSUE_COMMENT_COLLECTION, 1095 + record, 1096 + }, 1097 + }), 1098 + ); 1099 + 1100 + return { 1101 + uri: result.uri, 1102 + rkey: parseAtUri(result.uri).rkey, 1103 + }; 1104 + }; 1105 + 1106 + export const setIssueState = async ( 1107 + agent: OAuthUserAgent, 1108 + issueUri: ResourceUri, 1109 + state: 'open' | 'closed', 1110 + ): Promise<RecordCreationResult> => { 1111 + const rpc = createAuthRpc(agent); 1112 + const record: ShTangledRepoIssueState.Main = { 1113 + $type: 'sh.tangled.repo.issue.state', 1114 + issue: issueUri, 1115 + state: 1116 + state === 'closed' 1117 + ? 'sh.tangled.repo.issue.state.closed' 1118 + : 'sh.tangled.repo.issue.state.open', 1119 + }; 1120 + 1121 + const result = await ok( 1122 + rpc.post('com.atproto.repo.createRecord', { 1123 + input: { 1124 + repo: agent.sub, 1125 + collection: ISSUE_STATE_COLLECTION, 1126 + record, 1127 + }, 1128 + }), 1129 + ); 1130 + 1131 + return { 1132 + uri: result.uri, 1133 + rkey: parseAtUri(result.uri).rkey, 1134 + }; 1135 + }; 1136 + 1137 + export const createPull = async ( 1138 + agent: OAuthUserAgent, 1139 + repo: RepoContext, 1140 + input: CreatePullInput, 1141 + ): Promise<RecordCreationResult> => { 1142 + const rpc = createAuthRpc(agent); 1143 + const patchBytes = new Uint8Array(gzipSync(strToU8(input.patch))); 1144 + const patchBlob = new Blob([patchBytes.buffer], { 1145 + type: 'application/gzip', 1146 + }); 1147 + 1148 + const uploaded = await ok( 1149 + rpc.post('com.atproto.repo.uploadBlob', { 1150 + input: patchBlob, 1151 + }), 1152 + ); 1153 + 1154 + const record: ShTangledRepoPull.Main = { 1155 + $type: 'sh.tangled.repo.pull', 1156 + title: input.title.trim(), 1157 + body: optionalString(input.body), 1158 + target: { 1159 + repo: repo.repoDid, 1160 + branch: input.targetBranch, 1161 + }, 1162 + source: { 1163 + branch: input.sourceBranch, 1164 + }, 1165 + rounds: [ 1166 + { 1167 + createdAt: new Date().toISOString(), 1168 + patchBlob: uploaded.blob, 1169 + }, 1170 + ], 1171 + createdAt: new Date().toISOString(), 1172 + mentions: extractDids(input.body), 1173 + references: extractAtUris(input.body), 1174 + }; 1175 + 1176 + const result = await ok( 1177 + rpc.post('com.atproto.repo.createRecord', { 1178 + input: { 1179 + repo: agent.sub, 1180 + collection: PULL_COLLECTION, 1181 + record, 1182 + }, 1183 + }), 1184 + ); 1185 + 1186 + return { 1187 + uri: result.uri, 1188 + rkey: parseAtUri(result.uri).rkey, 1189 + }; 1190 + }; 1191 + 1192 + export const createPullComment = async ( 1193 + agent: OAuthUserAgent, 1194 + pullUri: ResourceUri, 1195 + body: string, 1196 + ): Promise<RecordCreationResult> => { 1197 + const rpc = createAuthRpc(agent); 1198 + const record: ShTangledRepoPullComment.Main = { 1199 + $type: 'sh.tangled.repo.pull.comment', 1200 + pull: pullUri, 1201 + body: body.trim(), 1202 + createdAt: new Date().toISOString(), 1203 + mentions: extractDids(body), 1204 + references: extractAtUris(body), 1205 + }; 1206 + 1207 + const result = await ok( 1208 + rpc.post('com.atproto.repo.createRecord', { 1209 + input: { 1210 + repo: agent.sub, 1211 + collection: PULL_COMMENT_COLLECTION, 1212 + record, 1213 + }, 1214 + }), 1215 + ); 1216 + 1217 + return { 1218 + uri: result.uri, 1219 + rkey: parseAtUri(result.uri).rkey, 1220 + }; 1221 + }; 1222 + 1223 + export const setPullStatus = async ( 1224 + agent: OAuthUserAgent, 1225 + pullUri: ResourceUri, 1226 + status: 'open' | 'closed' | 'merged', 1227 + ): Promise<RecordCreationResult> => { 1228 + const rpc = createAuthRpc(agent); 1229 + const record: ShTangledRepoPullStatus.Main = { 1230 + $type: 'sh.tangled.repo.pull.status', 1231 + pull: pullUri, 1232 + status: 1233 + status === 'merged' 1234 + ? 'sh.tangled.repo.pull.status.merged' 1235 + : status === 'closed' 1236 + ? 'sh.tangled.repo.pull.status.closed' 1237 + : 'sh.tangled.repo.pull.status.open', 1238 + }; 1239 + 1240 + const result = await ok( 1241 + rpc.post('com.atproto.repo.createRecord', { 1242 + input: { 1243 + repo: agent.sub, 1244 + collection: PULL_STATUS_COLLECTION, 1245 + record, 1246 + }, 1247 + }), 1248 + ); 1249 + 1250 + return { 1251 + uri: result.uri, 1252 + rkey: parseAtUri(result.uri).rkey, 1253 + }; 1254 + }; 1255 + 1256 + export const decodeBlobText = (blob: BlobResponse): string => { 1257 + if (!blob.content) { 1258 + return ''; 1259 + } 1260 + 1261 + if (blob.encoding === 'base64') { 1262 + const buffer = Uint8Array.from(atob(blob.content), (char) => char.charCodeAt(0)); 1263 + return new TextDecoder().decode(buffer); 1264 + } 1265 + 1266 + return blob.content; 1267 + }; 1268 + 1269 + export const buildBlobDataUrl = (blob: BlobResponse): string | null => { 1270 + if (!blob.content || !blob.mimeType) { 1271 + return null; 1272 + } 1273 + 1274 + if (blob.encoding === 'base64') { 1275 + return `data:${blob.mimeType};base64,${blob.content}`; 1276 + } 1277 + 1278 + return `data:${blob.mimeType};charset=utf-8,${encodeURIComponent(blob.content)}`; 1279 + }; 1280 + 1281 + export const resolveAvatarUrl = async (identifier: string): Promise<string | null> => { 1282 + const actor = await resolveActor(identifier); 1283 + const tangledProfile = await getOptionalRecord<ShTangledActorProfile.Main>( 1284 + actor, 1285 + ACTOR_PROFILE_COLLECTION, 1286 + 'self', 1287 + ); 1288 + const tangledCid = extractBlobCid(tangledProfile?.avatar); 1289 + if (tangledCid) { 1290 + return buildPdsBlobUrl(actor.pds, actor.did, tangledCid); 1291 + } 1292 + 1293 + const bskyProfile = await getOptionalRecord<ProfileWithAvatar>( 1294 + actor, 1295 + APP_BSKY_PROFILE_COLLECTION, 1296 + 'self', 1297 + ); 1298 + const bskyCid = extractBlobCid(bskyProfile?.avatar); 1299 + if (bskyCid) { 1300 + return buildPdsBlobUrl(actor.pds, actor.did, bskyCid); 1301 + } 1302 + 1303 + return null; 1304 + }; 1305 + 1306 + export const parseAtUri = (uri: string): { did: Did; collection: Nsid; rkey: string } => { 1307 + if (!uri.startsWith('at://')) { 1308 + throw new Error(`Invalid at-uri: ${uri}`); 1309 + } 1310 + 1311 + const parts = uri.slice(5).split('/'); 1312 + const [did, collection, ...rkey] = parts; 1313 + return { 1314 + did: asDid(did), 1315 + collection: asNsid(collection), 1316 + rkey: rkey.join('/'), 1317 + }; 1318 + }; 1319 + 1320 + export const extractBlobCid = (blob: unknown): string | null => { 1321 + if (!blob || typeof blob !== 'object') { 1322 + return null; 1323 + } 1324 + 1325 + const value = blob as Record<string, unknown>; 1326 + const ref = value.ref; 1327 + 1328 + if (typeof ref === 'string') { 1329 + return ref; 1330 + } 1331 + 1332 + if (ref && typeof ref === 'object') { 1333 + const maybeLink = (ref as Record<string, unknown>).$link; 1334 + if (typeof maybeLink === 'string') { 1335 + return maybeLink; 1336 + } 1337 + } 1338 + 1339 + const maybeCid = value.cid; 1340 + return typeof maybeCid === 'string' ? maybeCid : null; 1341 + }; 1342 + 1343 + const findNumberedRecord = <T extends { number: number; rkey: string; uri: string }>( 1344 + records: T[], 1345 + ref: string, 1346 + ): T | undefined => { 1347 + const trimmed = ref.trim(); 1348 + if (/^\d+$/.test(trimmed)) { 1349 + const number = Number(trimmed); 1350 + return records.find((record) => record.number === number); 1351 + } 1352 + 1353 + return records.find( 1354 + (record) => 1355 + record.rkey === trimmed || 1356 + record.uri === trimmed || 1357 + parseAtUri(record.uri).rkey === trimmed, 1358 + ); 1359 + }; 1360 + 1361 + const optionalString = (value: string): string | undefined => { 1362 + const trimmed = value.trim(); 1363 + return trimmed.length > 0 ? trimmed : undefined; 1364 + }; 1365 + 1366 + const uniq = <T extends string>(values: T[]): T[] | undefined => { 1367 + if (values.length === 0) { 1368 + return undefined; 1369 + } 1370 + 1371 + return [...new Set(values)]; 1372 + }; 1373 + 1374 + const extractDids = (input: string): Did[] | undefined => 1375 + uniq( 1376 + Array.from( 1377 + input.matchAll(/\bdid:[a-z0-9]+:[A-Za-z0-9._:%-]+\b/gi), 1378 + (match) => match[0] as Did, 1379 + ), 1380 + ); 1381 + 1382 + const extractAtUris = (input: string): ResourceUri[] | undefined => 1383 + uniq( 1384 + Array.from( 1385 + input.matchAll(/\bat:\/\/[^\s<>()]+/gi), 1386 + (match) => match[0] as ResourceUri, 1387 + ), 1388 + ); 1389 + 1390 + const sortByCreatedAt = <T extends { value: { createdAt?: string } }>(left: T, right: T): number => 1391 + new Date(left.value.createdAt ?? 0).getTime() - new Date(right.value.createdAt ?? 0).getTime(); 1392 + 1393 + const asActorIdentifier = (value: string): ActorIdentifier => value as ActorIdentifier; 1394 + 1395 + const asDid = (value: string): Did => { 1396 + if (!value.startsWith('did:')) { 1397 + throw new Error(`Invalid DID: ${value}`); 1398 + } 1399 + 1400 + return value as Did; 1401 + }; 1402 + 1403 + const asNsid = (value: string): Nsid => { 1404 + if (value.split('.').length < 3) { 1405 + throw new Error(`Invalid NSID: ${value}`); 1406 + } 1407 + 1408 + return value as Nsid; 1409 + }; 1410 + 1411 + const buildPdsBlobUrl = (pds: string, did: Did, cid: string): string => { 1412 + const url = new URL('/xrpc/com.atproto.sync.getBlob', normalizeServiceUrl(pds)); 1413 + url.searchParams.set('did', did); 1414 + url.searchParams.set('cid', cid); 1415 + return url.toString(); 1416 + };
+6
src/lib/api/identity.ts
··· 1 + export { 2 + createAuthRpc, 3 + identityResolver, 4 + resolveActor, 5 + resolveAvatarUrl, 6 + } from './core';
+15
src/lib/api/issues.ts
··· 1 + export { 2 + createIssue, 3 + createIssueComment, 4 + getIssue, 5 + listIssues, 6 + listIssuesPage, 7 + setIssueState, 8 + } from './core'; 9 + 10 + export type { 11 + CreateIssueInput, 12 + IssueComment, 13 + IssueDetail, 14 + IssueSummary, 15 + } from './core';
+16
src/lib/api/pulls.ts
··· 1 + export { 2 + createPull, 3 + createPullComment, 4 + fetchPullRoundPatch, 5 + getPull, 6 + listPulls, 7 + listPullsPage, 8 + setPullStatus, 9 + } from './core'; 10 + 11 + export type { 12 + CreatePullInput, 13 + PullComment, 14 + PullDetail, 15 + PullSummary, 16 + } from './core';
+10
src/lib/api/records.ts
··· 1 + export { 2 + extractBlobCid, 3 + parseAtUri, 4 + } from './core'; 5 + 6 + export type { 7 + HydratedRecord, 8 + PaginatedResult, 9 + RecordCreationResult, 10 + } from './core';
+31
src/lib/api/repos.ts
··· 1 + export { 2 + buildBlobDataUrl, 3 + compareBranches, 4 + decodeBlobText, 5 + getRepo, 6 + getRepoBlob, 7 + getRepoBranches, 8 + getRepoDefaultBranch, 9 + getRepoLanguages, 10 + getRepoLog, 11 + getRepoTags, 12 + getRepoTree, 13 + listRepoRecords, 14 + } from './core'; 15 + 16 + export type { 17 + BlobResponse, 18 + BranchEntry, 19 + BranchResponse, 20 + CommitHeadline, 21 + ComparePatch, 22 + CompareResponse, 23 + DefaultBranchResponse, 24 + LanguageResponse, 25 + LogResponse, 26 + RepoContext, 27 + RepoRecord, 28 + TagResponse, 29 + TreeEntry, 30 + TreeResponse, 31 + } from './core';
+10
src/lib/api/stars.ts
··· 1 + export { 2 + createRepoStar, 3 + deleteRepoStar, 4 + getRepoStarCount, 5 + getRepoStarSummary, 6 + } from './core'; 7 + 8 + export type { 9 + RepoStarSummary, 10 + } from './core';
+85
src/pages/repo/code-helpers.ts
··· 1 + import { createEffect, createSignal, onCleanup } from 'solid-js'; 2 + 3 + export const LOADING_DELAY_MS = 100; 4 + 5 + export const resolveDefaultBranchName = ( 6 + branches: { is_default?: boolean; reference: { name: string } }[] | undefined, 7 + ): string | undefined => 8 + branches?.find((branch) => branch.is_default)?.reference.name ?? branches?.[0]?.reference.name; 9 + 10 + export const resolveRouteRefAndPath = ( 11 + initialRef: string, 12 + initialPath: string, 13 + branchNames: string[] | undefined, 14 + ): { ref: string; path: string } => { 15 + if (!branchNames || !initialPath) { 16 + return { ref: initialRef, path: initialPath }; 17 + } 18 + 19 + const names = new Set(branchNames); 20 + const pathSegments = initialPath.split('/').filter(Boolean); 21 + let resolved = { ref: initialRef, path: initialPath }; 22 + 23 + for (let index = 0; index < pathSegments.length; index += 1) { 24 + const candidate = [initialRef, ...pathSegments.slice(0, index + 1)].join('/'); 25 + if (names.has(candidate)) { 26 + resolved = { 27 + ref: candidate, 28 + path: pathSegments.slice(index + 1).join('/'), 29 + }; 30 + } 31 + } 32 + 33 + return resolved; 34 + }; 35 + 36 + export const hasNamedRef = ( 37 + name: string, 38 + branches: { reference: { name: string } }[], 39 + tags: { name: string }[], 40 + ): boolean => branches.some((branch) => branch.reference.name === name) || tags.some((tag) => tag.name === name); 41 + 42 + export const normalizeLanguages = ( 43 + languages: Array<{ name: string; percentage: number; size: number; color?: string }>, 44 + totalSize?: number, 45 + ): Array<{ name: string; percentage: number; size: number; color?: string }> => { 46 + const computedTotalSize = totalSize ?? languages.reduce((sum, language) => sum + language.size, 0); 47 + return languages 48 + .map((language) => ({ 49 + ...language, 50 + percentage: computedTotalSize > 0 ? (language.size / computedTotalSize) * 100 : language.percentage, 51 + })) 52 + .sort((left, right) => right.size - left.size || left.name.localeCompare(right.name)); 53 + }; 54 + 55 + export const useDelayedLoading = (pending: () => boolean, delayMs = LOADING_DELAY_MS) => { 56 + const [visible, setVisible] = createSignal(false); 57 + let timer: ReturnType<typeof setTimeout> | undefined; 58 + 59 + createEffect(() => { 60 + if (pending()) { 61 + if (timer === undefined) { 62 + timer = setTimeout(() => { 63 + timer = undefined; 64 + setVisible(true); 65 + }, delayMs); 66 + } 67 + return; 68 + } 69 + 70 + if (timer !== undefined) { 71 + clearTimeout(timer); 72 + timer = undefined; 73 + } 74 + setVisible(false); 75 + }); 76 + 77 + onCleanup(() => { 78 + if (timer !== undefined) clearTimeout(timer); 79 + }); 80 + 81 + return visible; 82 + }; 83 + 84 + export const shouldAnimateNavigation = (event: MouseEvent) => 85 + event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey && !event.defaultPrevented;
+2 -80
src/pages/repo/code.tsx
··· 2 2 import { Download, File, Folder, GitBranch, GitCommitHorizontal, List } from 'lucide-solid'; 3 3 import { A, useNavigate, useParams } from '@solidjs/router'; 4 4 import { createQuery, keepPreviousData, useQueryClient } from '@tanstack/solid-query'; 5 - import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, type Component } from 'solid-js'; 5 + import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; 6 6 import { buildBlobDataUrl, decodeBlobText, getRepoBlob, getRepoBranches, getRepoDefaultBranch, getRepoLanguages, getRepoLog, getRepoTags, getRepoTree } from '../../lib/api'; 7 7 import { Avatar, ErrorState, PlaceholderAvatar, buttonStyles, cardStyles } from '../../components/common'; 8 8 import { CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoTreeSkeleton } from '../../components/repo'; 9 9 import { RepoFrame, useRepoQuery } from './shared'; 10 10 import { blobHref, countLines, decodeRoutePath, formatBytes, formatLanguagePercent, formatRelativeTime, getErrorMessage, getParentPath, imageLike, isDirectory, joinPath, languageColor, markdownLike, safeDecode, sortedTreeEntries, svgLike, treeHref, videoLike } from '../../lib/repo-utils'; 11 - 12 - const resolveDefaultBranchName = (branches: { is_default?: boolean; reference: { name: string } }[] | undefined): string | undefined => 13 - branches?.find((branch) => branch.is_default)?.reference.name ?? branches?.[0]?.reference.name; 14 - 15 - const resolveRouteRefAndPath = ( 16 - initialRef: string, 17 - initialPath: string, 18 - branchNames: string[] | undefined, 19 - ): { ref: string; path: string } => { 20 - if (!branchNames || !initialPath) { 21 - return { ref: initialRef, path: initialPath }; 22 - } 23 - 24 - const names = new Set(branchNames); 25 - const pathSegments = initialPath.split('/').filter(Boolean); 26 - let resolved = { ref: initialRef, path: initialPath }; 27 - 28 - for (let index = 0; index < pathSegments.length; index += 1) { 29 - const candidate = [initialRef, ...pathSegments.slice(0, index + 1)].join('/'); 30 - if (names.has(candidate)) { 31 - resolved = { 32 - ref: candidate, 33 - path: pathSegments.slice(index + 1).join('/'), 34 - }; 35 - } 36 - } 37 - 38 - return resolved; 39 - }; 40 - 41 - const hasNamedRef = (name: string, branches: { reference: { name: string } }[], tags: { name: string }[]): boolean => 42 - branches.some((branch) => branch.reference.name === name) || tags.some((tag) => tag.name === name); 43 - 44 - const normalizeLanguages = ( 45 - languages: Array<{ name: string; percentage: number; size: number; color?: string }>, 46 - totalSize?: number, 47 - ): Array<{ name: string; percentage: number; size: number; color?: string }> => { 48 - const computedTotalSize = totalSize ?? languages.reduce((sum, language) => sum + language.size, 0); 49 - return languages 50 - .map((language) => ({ 51 - ...language, 52 - percentage: computedTotalSize > 0 ? (language.size / computedTotalSize) * 100 : language.percentage, 53 - })) 54 - .sort((left, right) => right.size - left.size || left.name.localeCompare(right.name)); 55 - }; 56 - 57 - const LOADING_DELAY_MS = 100; 58 - 59 - const useDelayedLoading = (pending: () => boolean, delayMs = LOADING_DELAY_MS) => { 60 - const [visible, setVisible] = createSignal(false); 61 - let timer: ReturnType<typeof setTimeout> | undefined; 62 - 63 - createEffect(() => { 64 - if (pending()) { 65 - if (timer === undefined) { 66 - timer = setTimeout(() => { 67 - timer = undefined; 68 - setVisible(true); 69 - }, delayMs); 70 - } 71 - return; 72 - } 73 - 74 - if (timer !== undefined) { 75 - clearTimeout(timer); 76 - timer = undefined; 77 - } 78 - setVisible(false); 79 - }); 80 - 81 - onCleanup(() => { 82 - if (timer !== undefined) clearTimeout(timer); 83 - }); 84 - 85 - return visible; 86 - }; 11 + import { LOADING_DELAY_MS, hasNamedRef, normalizeLanguages, resolveDefaultBranchName, resolveRouteRefAndPath, shouldAnimateNavigation, useDelayedLoading } from './code-helpers'; 87 12 88 13 const PathBreadcrumbs: Component<{ 89 14 repo: Parameters<typeof treeHref>[0]; ··· 153 78 </div> 154 79 ); 155 80 }; 156 - 157 - const shouldAnimateNavigation = (event: MouseEvent) => 158 - event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey && !event.defaultPrevented; 159 81 160 82 const RepoCodePageLegacy: Component = () => { 161 83 const params = useParams();
+44
src/pages/repo/issues-helpers.ts
··· 1 + import type { IssueComment } from '../../lib/api'; 2 + 3 + export type IssueCommentThread = { 4 + item: IssueComment; 5 + replies: IssueComment[]; 6 + }; 7 + 8 + export const buildIssueCommentThreads = (comments: IssueComment[]): IssueCommentThread[] => { 9 + const byUri = new Map(comments.map((comment) => [comment.uri, comment])); 10 + const topLevel: IssueComment[] = []; 11 + const repliesByRoot = new Map<string, IssueComment[]>(); 12 + 13 + const findRoot = (comment: IssueComment): IssueComment | null => { 14 + let current = comment; 15 + const visited = new Set<string>([comment.uri]); 16 + 17 + while (current.value.replyTo) { 18 + const parent = byUri.get(current.value.replyTo); 19 + if (!parent || visited.has(parent.uri)) { 20 + return current === comment ? null : current; 21 + } 22 + 23 + current = parent; 24 + visited.add(current.uri); 25 + } 26 + 27 + return current; 28 + }; 29 + 30 + for (const comment of comments) { 31 + const root = findRoot(comment); 32 + if (!root || root.uri === comment.uri) { 33 + topLevel.push(comment); 34 + continue; 35 + } 36 + 37 + repliesByRoot.set(root.uri, [...(repliesByRoot.get(root.uri) ?? []), comment]); 38 + } 39 + 40 + return topLevel.map((item) => ({ 41 + item, 42 + replies: repliesByRoot.get(item.uri) ?? [], 43 + })); 44 + };
+2 -44
src/pages/repo/issues.tsx
··· 4 4 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 5 5 import type { ResourceUri } from '@atcute/lexicons/syntax'; 6 6 import { For, Match, Show, Switch, createMemo, createSignal, type Component } from 'solid-js'; 7 - import { createIssue, createIssueComment, getIssue, listIssuesPage, parseAtUri, resolveActor, setIssueState, type IssueComment, type RepoContext } from '../../lib/api'; 7 + import { createIssue, createIssueComment, getIssue, listIssuesPage, parseAtUri, resolveActor, setIssueState, type RepoContext } from '../../lib/api'; 8 8 import { useAuth } from '../../lib/auth'; 9 9 import { Avatar, ErrorState, StateBadge, ToggleButton, buttonStyles, cardStyles, inputStyles, textareaStyles } from '../../components/common'; 10 10 import { MarkdownBlock, PaginationControls, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 11 11 import { RepoFrame, issueQueryKey, issuesQueryKey, useRepoQuery } from './shared'; 12 12 import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, issueHref, parseIntegerSearchParam, uniqueCommenters } from '../../lib/repo-utils'; 13 - 14 - type IssueCommentThread = { 15 - item: IssueComment; 16 - replies: IssueComment[]; 17 - }; 18 - 19 - const buildIssueCommentThreads = (comments: IssueComment[]): IssueCommentThread[] => { 20 - const byUri = new Map(comments.map((comment) => [comment.uri, comment])); 21 - const topLevel: IssueComment[] = []; 22 - const repliesByRoot = new Map<string, IssueComment[]>(); 23 - 24 - const findRoot = (comment: IssueComment): IssueComment | null => { 25 - let current = comment; 26 - const visited = new Set<string>([comment.uri]); 27 - 28 - while (current.value.replyTo) { 29 - const parent = byUri.get(current.value.replyTo); 30 - if (!parent || visited.has(parent.uri)) { 31 - return current === comment ? null : current; 32 - } 33 - 34 - current = parent; 35 - visited.add(current.uri); 36 - } 37 - 38 - return current; 39 - }; 40 - 41 - for (const comment of comments) { 42 - const root = findRoot(comment); 43 - if (!root || root.uri === comment.uri) { 44 - topLevel.push(comment); 45 - continue; 46 - } 47 - 48 - repliesByRoot.set(root.uri, [...(repliesByRoot.get(root.uri) ?? []), comment]); 49 - } 50 - 51 - return topLevel.map((item) => ({ 52 - item, 53 - replies: repliesByRoot.get(item.uri) ?? [], 54 - })); 55 - }; 13 + import { buildIssueCommentThreads } from './issues-helpers'; 56 14 57 15 export const IssuesPage: Component = () => { 58 16 const auth = useAuth();
+15
src/pages/repo/pulls-helpers.ts
··· 1 + import type { SearchParamValue } from '../../lib/repo-utils'; 2 + 3 + export type PullStateFilter = 'open' | 'closed' | 'merged'; 4 + 5 + export const parsePullStateFilter = (value: SearchParamValue): PullStateFilter => { 6 + if (value === 'state:closed') { 7 + return 'closed'; 8 + } 9 + 10 + if (value === 'state:merged') { 11 + return 'merged'; 12 + } 13 + 14 + return 'open'; 15 + };
+2 -5
src/pages/repo/pulls.tsx
··· 9 9 import { BranchPill, CommentCard, DiffView, MarkdownBlock, PaginationControls, PullDiffView, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 10 10 import { RepoFrame, pullQueryKey, pullsQueryKey, useRepoQuery } from './shared'; 11 11 import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, parseIntegerSearchParam, pullHref, uniqueCommenters } from '../../lib/repo-utils'; 12 + import { parsePullStateFilter, type PullStateFilter } from './pulls-helpers'; 12 13 13 14 export const PullsPage: Component = () => { 14 15 const auth = useAuth(); 15 16 const repoQuery = useRepoQuery(); 16 17 const [searchParams, setSearchParams] = useSearchParams(); 17 - const stateFilter = createMemo<'open' | 'closed' | 'merged'>(() => { 18 - if (searchParams.q === 'state:closed') return 'closed'; 19 - if (searchParams.q === 'state:merged') return 'merged'; 20 - return 'open'; 21 - }); 18 + const stateFilter = createMemo<PullStateFilter>(() => parsePullStateFilter(searchParams.q)); 22 19 const filterQuery = createMemo(() => `state:${stateFilter()}`); 23 20 const pageOffset = createMemo(() => parseIntegerSearchParam(searchParams.offset, 0, 0)); 24 21 const pageLimit = createMemo(() => parseIntegerSearchParam(searchParams.limit, REPO_LIST_PAGE_LIMIT, 1));
+22 -3
src/pages/repo/shared.tsx
··· 1 1 import { CircleDot, GitPullRequest, Globe, LoaderCircle, Rss, SquareChartGantt, Star } from 'lucide-solid'; 2 - import { A, useParams } from '@solidjs/router'; 2 + import { A, type RouteSectionProps, useParams } from '@solidjs/router'; 3 3 import { createQuery } from '@tanstack/solid-query'; 4 - import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, type Component, type JSX } from 'solid-js'; 4 + import { For, Match, Show, Switch, createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type Component, type JSX } from 'solid-js'; 5 5 import { createRepoStar, deleteRepoStar, getRepo, getRepoStarSummary } from '../../lib/api'; 6 6 import { useAuth } from '../../lib/auth'; 7 7 import { useLiveEvents } from '../../lib/live-events'; ··· 17 17 18 18 type OptimisticStarState = { state: 'starred'; rkey?: string } | { state: 'unstarred'; rkey: string }; 19 19 20 - export const useRepoQuery = () => { 20 + const createRepoQuery = () => { 21 21 const params = useParams(); 22 22 const owner = createMemo(() => params.owner ?? ''); 23 23 const repo = createMemo(() => params.repo ?? ''); ··· 27 27 enabled: Boolean(owner() && repo()), 28 28 queryFn: async () => getRepo(owner(), repo()), 29 29 })); 30 + }; 31 + 32 + type RepoQuery = ReturnType<typeof createRepoQuery>; 33 + 34 + const RepoQueryContext = createContext<RepoQuery>(); 35 + 36 + export const RepoLayout: Component<RouteSectionProps> = (props) => { 37 + const repoQuery = createRepoQuery(); 38 + 39 + return <RepoQueryContext.Provider value={repoQuery}>{props.children}</RepoQueryContext.Provider>; 40 + }; 41 + 42 + export const useRepoQuery = () => { 43 + const repoQuery = useContext(RepoQueryContext); 44 + if (!repoQuery) { 45 + throw new Error('useRepoQuery must be used inside RepoLayout'); 46 + } 47 + 48 + return repoQuery; 30 49 }; 31 50 32 51 export const RepoFrame: Component<{
+37
src/routes.tsx
··· 1 + import { Route, Router } from '@solidjs/router'; 2 + import type { Component } from 'solid-js'; 3 + 4 + import { RootShell } from './layout'; 5 + import { 6 + BlobPage, 7 + IssuePage, 8 + IssuesPage, 9 + NewIssuePage, 10 + NewPullPage, 11 + PullPage, 12 + PullsPage, 13 + RepoCodePage, 14 + } from './pages/repo'; 15 + import { RepoLayout } from './pages/repo/shared'; 16 + import { HomePage } from './views/home'; 17 + import { NotFoundPage } from './views/not-found'; 18 + import { OAuthCallbackPage } from './views/oauth-callback'; 19 + 20 + export const AppRoutes: Component = () => ( 21 + <Router root={RootShell}> 22 + <Route path="/" component={HomePage} /> 23 + <Route path="/oauth/callback" component={OAuthCallbackPage} /> 24 + <Route path="/:owner/:repo" component={RepoLayout}> 25 + <Route path="/" component={RepoCodePage} /> 26 + <Route path="/issues/new" component={NewIssuePage} /> 27 + <Route path="/issues/:issueRef" component={IssuePage} /> 28 + <Route path="/issues" component={IssuesPage} /> 29 + <Route path="/pulls/new" component={NewPullPage} /> 30 + <Route path="/pulls/:pullRef" component={PullPage} /> 31 + <Route path="/pulls" component={PullsPage} /> 32 + <Route path="/blob/:ref/*path" component={BlobPage} /> 33 + <Route path="/tree/:ref/*path" component={RepoCodePage} /> 34 + </Route> 35 + <Route path="*404" component={NotFoundPage} /> 36 + </Router> 37 + );
+134
src/views/home.tsx
··· 1 + import clsx from 'clsx'; 2 + import { A, useNavigate } from '@solidjs/router'; 3 + import { createQuery } from '@tanstack/solid-query'; 4 + import { For, Show, createMemo, createSignal, type Component } from 'solid-js'; 5 + 6 + import { LoadingState, buttonStyles, cardStyles, inputStyles } from '../components/common'; 7 + import { listRepoRecords, resolveActor } from '../lib/api'; 8 + import { useAuth } from '../lib/auth'; 9 + import { formatRelativeTime, loadRecentRepos, parseRepoJump } from '../lib/repo-utils'; 10 + 11 + const SAMPLE_REPOS = [ 12 + { owner: 'mary.my.id', slug: 'atcute' }, 13 + { owner: 'mary.my.id', slug: 'boat' }, 14 + { owner: 'mary.my.id', slug: 'aglais' }, 15 + ]; 16 + 17 + export const HomePage: Component = () => { 18 + const auth = useAuth(); 19 + const [jump, setJump] = createSignal(''); 20 + const recentRepos = createMemo(loadRecentRepos); 21 + const navigate = useNavigate(); 22 + 23 + const ownReposQuery = createQuery(() => ({ 24 + queryKey: ['own-repos', auth.currentDid()], 25 + enabled: Boolean(auth.currentDid()), 26 + queryFn: async () => { 27 + const actor = await resolveActor(auth.currentDid()!); 28 + return listRepoRecords(actor); 29 + }, 30 + })); 31 + 32 + const onSubmit = (event: SubmitEvent) => { 33 + event.preventDefault(); 34 + const parsed = parseRepoJump(jump()); 35 + if (!parsed) { 36 + return; 37 + } 38 + 39 + navigate(`/${parsed.owner}/${parsed.repo}`); 40 + }; 41 + 42 + return ( 43 + <div class="untangled-home-grid"> 44 + <div class="flex flex-col gap-4"> 45 + <div class={cardStyles('p-6')}> 46 + <div class="flex items-center gap-3 mb-6"> 47 + <img src="/static/logos/dolly.svg" alt="" class="size-12" /> 48 + <div> 49 + <h1 class="text-2xl font-bold m-0">untangled</h1> 50 + <p class="m-0 text-sm text-gray-500 dark:text-gray-400">tightly-knit social coding</p> 51 + </div> 52 + </div> 53 + 54 + <form onSubmit={onSubmit} class="flex flex-col gap-3"> 55 + <label class="text-sm font-medium">repository</label> 56 + <div class="flex gap-2"> 57 + <input 58 + value={jump()} 59 + onInput={(event) => setJump(event.currentTarget.value)} 60 + placeholder="mary.my.id/atcute" 61 + class={inputStyles()} 62 + /> 63 + <button type="submit" class={clsx(buttonStyles('primary'), 'shrink-0')}> 64 + open 65 + </button> 66 + </div> 67 + </form> 68 + 69 + <div class="mt-8"> 70 + <div class="text-sm font-medium mb-3">sample repositories</div> 71 + <div class="grid gap-2 sm:grid-cols-2"> 72 + <For each={SAMPLE_REPOS}> 73 + {(repo) => ( 74 + <A href={`/${repo.owner}/${repo.slug}`} class={cardStyles('p-4 hover:bg-gray-50 dark:hover:bg-gray-700/30 no-underline')}> 75 + <div class="font-medium"> 76 + {repo.owner}/{repo.slug} 77 + </div> 78 + </A> 79 + )} 80 + </For> 81 + </div> 82 + </div> 83 + </div> 84 + </div> 85 + 86 + <div class="flex flex-col gap-4"> 87 + <div class={cardStyles('p-6')}> 88 + <div class="text-sm font-medium mb-3">recent</div> 89 + <Show 90 + when={recentRepos().length > 0} 91 + fallback={<div class="text-sm text-gray-500 dark:text-gray-400">No recent repositories.</div>} 92 + > 93 + <div class="flex flex-col gap-2"> 94 + <For each={recentRepos()}> 95 + {(repo) => ( 96 + <A href={`/${repo.owner}/${repo.slug}`} class="no-underline hover:no-underline rounded border border-gray-200 dark:border-gray-700 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30"> 97 + <div class="font-medium">{repo.title}</div> 98 + <div class="text-xs text-gray-500 dark:text-gray-400">{formatRelativeTime(repo.updatedAt)}</div> 99 + </A> 100 + )} 101 + </For> 102 + </div> 103 + </Show> 104 + </div> 105 + 106 + <Show when={auth.currentDid()}> 107 + <div class={cardStyles('p-6')}> 108 + <div class="text-sm font-medium mb-3">your repositories</div> 109 + <Show 110 + when={!ownReposQuery.isLoading} 111 + fallback={<LoadingState label="Loading repositories..." />} 112 + > 113 + <div class="flex flex-col gap-2"> 114 + <For each={ownReposQuery.data ?? []}> 115 + {(repo) => ( 116 + <A 117 + href={`/${auth.currentDid()}/${repo.value.name?.trim() || repo.rkey}`} 118 + class="no-underline hover:no-underline rounded border border-gray-200 dark:border-gray-700 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30" 119 + > 120 + <div class="font-medium">{repo.value.name?.trim() || repo.rkey}</div> 121 + <div class="text-xs text-gray-500 dark:text-gray-400"> 122 + {repo.value.description || repo.value.repoDid} 123 + </div> 124 + </A> 125 + )} 126 + </For> 127 + </div> 128 + </Show> 129 + </div> 130 + </Show> 131 + </div> 132 + </div> 133 + ); 134 + };
+9
src/views/not-found.tsx
··· 1 + import type { Component } from 'solid-js'; 2 + 3 + import { cardStyles } from '../components/common'; 4 + 5 + export const NotFoundPage: Component = () => ( 6 + <div class={cardStyles('p-10 text-center')}> 7 + <h1 class="text-2xl font-bold m-0">not found</h1> 8 + </div> 9 + );
+27
src/views/oauth-callback.tsx
··· 1 + import { useNavigate } from '@solidjs/router'; 2 + import { onMount, type Component } from 'solid-js'; 3 + 4 + import { LoadingState, cardStyles } from '../components/common'; 5 + import { useAuth } from '../lib/auth'; 6 + 7 + export const OAuthCallbackPage: Component = () => { 8 + const auth = useAuth(); 9 + const navigate = useNavigate(); 10 + 11 + onMount(() => { 12 + void (async () => { 13 + try { 14 + const target = await auth.completeSignIn(); 15 + navigate(target, { replace: true }); 16 + } catch { 17 + // Error is captured in auth state. 18 + } 19 + })(); 20 + }); 21 + 22 + return ( 23 + <div class={cardStyles('p-10 text-center')}> 24 + <LoadingState label={auth.error() ?? 'Completing OAuth sign-in...'} /> 25 + </div> 26 + ); 27 + };