csr tangled client in solid-js
15

Configure Feed

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

fork page

dawn (May 31, 2026, 6:04 PM +0300) 25710187 73260e54

+447 -38
+14 -2
src/index.css
··· 3986 3986 } 3987 3987 } 3988 3988 3989 + .untangled-fork-step .untangled-timeline-circle { 3990 + top: 4px; 3991 + } 3992 + 3993 + .untangled-fork-step-title { 3994 + min-height: 25px; 3995 + display: flex; 3996 + align-items: center; 3997 + } 3998 + 3999 + .untangled-repo-form-submit { 4000 + margin-top: 1rem; 4001 + } 4002 + 3989 4003 /* Checkbox specific overrides to prevent extra layout padding */ 3990 4004 .untangled-checkbox { 3991 4005 padding: 0 !important; 3992 4006 margin: 0 !important; 3993 4007 } 3994 - 3995 -
+1
src/lib/api.ts
··· 34 34 listRepoRecords, 35 35 compareFork, 36 36 createHiddenRef, 37 + forkRepo, 37 38 getRepoForkCount, 38 39 } from './api/repos'; 39 40 export {
+96 -14
src/lib/api/repos.ts
··· 749 749 return records; 750 750 }; 751 751 752 + const cleanedRepoName = (name: string): string => { 753 + const repoName = name.trim(); 754 + return repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName; 755 + }; 756 + 757 + const repoRkeyFromName = (name: string): string => cleanedRepoName(name).toLowerCase(); 758 + 759 + const forkSourceUrl = (sourceRepo: RepoContext): string => 760 + `${normalizeServiceUrl(sourceRepo.knot).replace(/\/+$/, '')}/${sourceRepo.repoDid}`; 761 + 762 + const deleteRepoFromKnot = async ( 763 + agent: OAuthUserAgent, 764 + knot: string, 765 + rkey: string, 766 + ): Promise<void> => { 767 + const knotDeleteClient = await createServiceAuthRpc(agent, knot, 'sh.tangled.repo.delete'); 768 + await ok( 769 + knotDeleteClient.post('sh.tangled.repo.delete', { 770 + input: { 771 + did: agent.sub, 772 + name: rkey, 773 + rkey, 774 + }, 775 + as: null, 776 + }), 777 + ); 778 + }; 779 + 752 780 export const createRepo = async ( 753 781 agent: OAuthUserAgent, 754 782 input: { ··· 758 786 knot: string; 759 787 }, 760 788 ): Promise<RecordCreationResult> => { 761 - const repoName = input.name.trim(); 762 - const cleanedName = repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName; 763 - const rkey = cleanedName.toLowerCase(); 789 + const cleanedName = cleanedRepoName(input.name); 790 + const rkey = repoRkeyFromName(input.name); 764 791 765 792 const knotClient = await createServiceAuthRpc(agent, input.knot, 'sh.tangled.repo.create'); 766 793 const createResult = (await ok( ··· 806 833 }; 807 834 } catch (pdsErr) { 808 835 try { 809 - const knotDeleteClient = await createServiceAuthRpc(agent, input.knot, 'sh.tangled.repo.delete'); 810 - await ok( 811 - knotDeleteClient.post('sh.tangled.repo.delete', { 812 - input: { 813 - did: agent.sub, 814 - name: rkey, 815 - rkey: rkey, 816 - }, 817 - as: null, 818 - }), 819 - ); 836 + await deleteRepoFromKnot(agent, input.knot, rkey); 820 837 } catch (deleteErr) { 821 838 console.error('Failed to clean up repo on knot:', deleteErr); 822 839 } 823 840 throw pdsErr; 824 841 } 825 842 }; 843 + 844 + export const forkRepo = async ( 845 + agent: OAuthUserAgent, 846 + input: { 847 + sourceRepo: RepoContext; 848 + name: string; 849 + description?: string; 850 + knot: string; 851 + }, 852 + ): Promise<RecordCreationResult> => { 853 + const cleanedName = cleanedRepoName(input.name); 854 + const rkey = repoRkeyFromName(input.name); 855 + 856 + const knotClient = await createServiceAuthRpc(agent, input.knot, 'sh.tangled.repo.create'); 857 + const createResult = (await ok( 858 + knotClient.post('sh.tangled.repo.create', { 859 + input: { 860 + rkey, 861 + name: rkey, 862 + source: forkSourceUrl(input.sourceRepo), 863 + }, 864 + }), 865 + )) as { repoDid?: string }; 866 + 867 + const repoDid = createResult.repoDid; 868 + if (!repoDid) { 869 + throw new Error('Knot failed to mint a repo DID.'); 870 + } 871 + 872 + const pdsRpc = createAuthRpc(agent); 873 + const record = { 874 + $type: 'sh.tangled.repo', 875 + name: cleanedName, 876 + knot: input.knot, 877 + repoDid, 878 + source: input.sourceRepo.repoDid || input.sourceRepo.record.uri, 879 + description: input.description?.trim() || '', 880 + createdAt: new Date().toISOString(), 881 + }; 882 + 883 + try { 884 + const result = await ok( 885 + pdsRpc.post('com.atproto.repo.createRecord', { 886 + input: { 887 + repo: agent.sub, 888 + collection: REPO_COLLECTION, 889 + rkey, 890 + record, 891 + }, 892 + }), 893 + ); 894 + 895 + return { 896 + uri: result.uri, 897 + rkey: parseAtUri(result.uri).rkey, 898 + }; 899 + } catch (pdsErr) { 900 + try { 901 + await deleteRepoFromKnot(agent, input.knot, rkey); 902 + } catch (deleteErr) { 903 + console.error('Failed to clean up fork on knot:', deleteErr); 904 + } 905 + throw pdsErr; 906 + } 907 + };
+1
src/pages/repo.tsx
··· 1 1 export { BlobPage, RepoCodePage } from './repo/code'; 2 2 export { CommitPage, CommitsPage } from './repo/commits'; 3 + export { ForkRepoPage } from './repo/fork'; 3 4 export { IssuePage, IssuesPage, NewIssuePage } from './repo/issues'; 4 5 export { NewPullPage, PullPage, PullsPage } from './repo/pulls'; 5 6 export { NewRepoPage } from './repo/new';
+314
src/pages/repo/fork.tsx
··· 1 + import { A, useNavigate } from '@solidjs/router'; 2 + import { createQuery, useQueryClient } from '@tanstack/solid-query'; 3 + import { GitFork, Info, LoaderCircle } from 'lucide-solid'; 4 + import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; 5 + 6 + import { Avatar, ErrorState, LoadingState, cardStyles } from '../../components/common'; 7 + import { resolveActor } from '../../lib/api/identity'; 8 + import { forkRepo, listRepoRecords, listUserKnots } from '../../lib/api/repos'; 9 + import { useAuth } from '../../lib/auth'; 10 + import { getErrorMessage } from '../../lib/repo-utils'; 11 + import { validateRepoName } from './new'; 12 + import { useRepoQuery } from './shared'; 13 + 14 + export const ForkRepoPage: Component = () => { 15 + const auth = useAuth(); 16 + const navigate = useNavigate(); 17 + const queryClient = useQueryClient(); 18 + const repoQuery = useRepoQuery(); 19 + 20 + const [name, setName] = createSignal(''); 21 + const [description, setDescription] = createSignal(''); 22 + const [selectedKnot, setSelectedKnot] = createSignal(''); 23 + const [saving, setSaving] = createSignal(false); 24 + const [error, setError] = createSignal<string | null>(null); 25 + const [initializedForRepo, setInitializedForRepo] = createSignal<string | null>(null); 26 + 27 + const viewerQuery = createQuery(() => ({ 28 + queryKey: ['viewer', auth.currentDid()], 29 + enabled: Boolean(auth.currentDid()), 30 + queryFn: async () => resolveActor(auth.currentDid()!), 31 + })); 32 + 33 + const knotsQuery = createQuery(() => ({ 34 + queryKey: ['knots', auth.currentDid()], 35 + enabled: Boolean(auth.currentDid()), 36 + queryFn: async () => listUserKnots(auth.currentDid()!), 37 + })); 38 + 39 + createEffect(() => { 40 + const repo = repoQuery.data; 41 + if (!repo || initializedForRepo() === repo.repoDid) { 42 + return; 43 + } 44 + 45 + setName(repo.slug); 46 + setDescription(repo.record.value.description ?? ''); 47 + setInitializedForRepo(repo.repoDid); 48 + }); 49 + 50 + createEffect(() => { 51 + const knots = knotsQuery.data; 52 + if (knots && knots.length === 1) { 53 + setSelectedKnot(knots[0]); 54 + } 55 + }); 56 + 57 + const descriptionLength = createMemo(() => description().length); 58 + const viewerLabel = createMemo(() => viewerQuery.data?.handle || auth.currentDid() || ''); 59 + 60 + const handleSubmit = async (event: SubmitEvent) => { 61 + event.preventDefault(); 62 + 63 + const agent = auth.agent(); 64 + const currentDid = auth.currentDid(); 65 + const sourceRepo = repoQuery.data; 66 + if (!agent || !currentDid || !sourceRepo) return; 67 + 68 + const repoName = name().trim(); 69 + const nameError = validateRepoName(repoName); 70 + if (nameError) { 71 + setError(nameError); 72 + return; 73 + } 74 + 75 + if (description().length > 140) { 76 + setError('description must be 140 characters or fewer.'); 77 + return; 78 + } 79 + 80 + const knot = selectedKnot(); 81 + if (!knot) { 82 + setError('please select a knot.'); 83 + return; 84 + } 85 + 86 + setSaving(true); 87 + setError(null); 88 + 89 + try { 90 + const existingRecords = await queryClient.fetchQuery({ 91 + queryKey: ['profile-repos', currentDid], 92 + queryFn: () => listRepoRecords(currentDid), 93 + }); 94 + 95 + const cleanedName = repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName; 96 + const rkey = cleanedName.toLowerCase(); 97 + const exists = existingRecords.some( 98 + (record) => record.rkey.toLowerCase() === rkey || record.value.name?.trim().toLowerCase() === rkey, 99 + ); 100 + 101 + if (exists) { 102 + setError(`you already have a repository named "${cleanedName}".`); 103 + setSaving(false); 104 + return; 105 + } 106 + 107 + const result = await forkRepo(agent, { 108 + sourceRepo, 109 + name: repoName, 110 + description: description(), 111 + knot, 112 + }); 113 + 114 + await Promise.all([ 115 + queryClient.invalidateQueries({ queryKey: ['profile-repos', currentDid] }), 116 + queryClient.invalidateQueries({ queryKey: ['repo-fork-count', sourceRepo.repoDid] }), 117 + ]); 118 + 119 + navigate(`/${viewerLabel() || currentDid}/${result.rkey}`); 120 + } catch (cause) { 121 + setError(getErrorMessage(cause)); 122 + } finally { 123 + setSaving(false); 124 + } 125 + }; 126 + 127 + return ( 128 + <Switch> 129 + <Match when={repoQuery.isLoading}> 130 + <div class={cardStyles('p-4')}> 131 + <LoadingState label="loading repository..." /> 132 + </div> 133 + </Match> 134 + <Match when={repoQuery.error}> 135 + <div class={cardStyles('p-4')}> 136 + <ErrorState message={getErrorMessage(repoQuery.error)} /> 137 + </div> 138 + </Match> 139 + <Match when={repoQuery.data}> 140 + {(repo) => ( 141 + <Show 142 + when={auth.currentDid()} 143 + fallback={ 144 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded p-4 relative flex gap-2 items-center dark:text-white"> 145 + please log in to fork this repository. 146 + </div> 147 + } 148 + > 149 + <div class="grid grid-cols-12 dark:text-white"> 150 + <div class="col-span-full md:col-start-3 md:col-span-8 px-4 py-1 mb-2"> 151 + <h1 class="text-xl font-bold dark:text-white mb-1 flex items-center gap-2 flex-wrap"> 152 + <GitFork class="size-5 shrink-0" /> 153 + <span>fork</span> 154 + <span class="flex items-center gap-1 font-normal"> 155 + <Avatar did={repo().owner.did} size="size-5" /> 156 + <A href={`/${repo().owner.handle}`} class="font-medium"> 157 + {repo().owner.handle} 158 + </A> 159 + <span class="text-gray-400 dark:text-gray-500">/</span> 160 + <A href={`/${repo().owner.handle}/${repo().slug}`} class="font-semibold"> 161 + {repo().slug} 162 + </A> 163 + </span> 164 + </h1> 165 + </div> 166 + 167 + <div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded overflow-hidden border border-gray-200 dark:border-gray-700"> 168 + <div class="flex items-center gap-3 p-3 md:px-5 border-b border-gray-100 dark:border-gray-700 text-gray-700 dark:text-gray-300"> 169 + <Info class="size-5 text-gray-500 dark:text-gray-400 shrink-0" /> 170 + <p class="text-sm"> 171 + a fork is your own copy of this repository. 172 + <br /> 173 + use it to work independently, then open a pull request back upstream. 174 + </p> 175 + </div> 176 + 177 + <form onSubmit={handleSubmit} class="p-4 md:px-6"> 178 + {error() && <p class="text-red-500 dark:text-red-400 text-sm mb-3">{error()}</p>} 179 + 180 + <div class="untangled-timeline-step untangled-timeline-step-1 untangled-fork-step"> 181 + <div class="untangled-timeline-circle"> 182 + <div class="untangled-timeline-circle-inner">1</div> 183 + </div> 184 + 185 + <div class="flex-1 pb-6 pt-1"> 186 + <h2 class="untangled-fork-step-title text-lg font-medium dark:text-white">details</h2> 187 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-3">basic repository information.</div> 188 + 189 + <div class="space-y-3 mt-3"> 190 + <div> 191 + <label for="fork-name" class="block text-sm font-bold dark:text-white mb-1"> 192 + REPOSITORY NAME 193 + </label> 194 + <div class="flex flex-col md:flex-row md:items-center w-full"> 195 + <div class="shrink-0 hidden md:flex items-center px-3 py-2 gap-1.5 text-sm text-gray-700 dark:text-gray-300 border border-r-0 border-gray-300 dark:border-gray-600 rounded-l bg-gray-50 dark:bg-gray-700 h-[38px]"> 196 + <Avatar did={auth.currentDid()!} size="size-5" /> 197 + <span>{viewerLabel()}</span> 198 + </div> 199 + <input 200 + type="text" 201 + id="fork-name" 202 + name="repo_name" 203 + value={name()} 204 + onInput={(event) => setName(event.currentTarget.value)} 205 + required 206 + class="flex-1 md:rounded-r md:rounded-l-none rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 h-[38px]" 207 + placeholder="repository-name" 208 + /> 209 + </div> 210 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 211 + choose a unique, descriptive name for your repository. use letters, numbers, and hyphens. 212 + </p> 213 + </div> 214 + 215 + <div class="pt-2"> 216 + <label for="fork-description" class="block text-sm font-bold dark:text-white mb-1"> 217 + DESCRIPTION 218 + </label> 219 + <input 220 + type="text" 221 + id="fork-description" 222 + name="description" 223 + value={description()} 224 + onInput={(event) => setDescription(event.currentTarget.value)} 225 + maxlength="140" 226 + class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 h-[38px]" 227 + placeholder="a brief description of your project..." 228 + /> 229 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 230 + optional. a short description to help others understand what your project does ({descriptionLength()}/140 characters). 231 + </p> 232 + </div> 233 + </div> 234 + </div> 235 + </div> 236 + 237 + <div class="untangled-timeline-step untangled-timeline-step-2 untangled-fork-step"> 238 + <div class="untangled-timeline-circle"> 239 + <div class="untangled-timeline-circle-inner">2</div> 240 + </div> 241 + 242 + <div class="flex-1 pt-1"> 243 + <h2 class="untangled-fork-step-title text-lg font-medium dark:text-white">knot selection</h2> 244 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-3">repository settings and hosting.</div> 245 + 246 + <div class="space-y-3 mt-3"> 247 + <div class="pt-2"> 248 + <label class="block text-sm font-bold dark:text-white mb-1"> 249 + SELECT A KNOT 250 + </label> 251 + <div class="w-full space-y-1 py-0"> 252 + <Show 253 + when={!knotsQuery.isLoading} 254 + fallback={<div class="text-sm text-gray-500 dark:text-gray-400">loading knots...</div>} 255 + > 256 + <Show 257 + when={knotsQuery.data && knotsQuery.data.length > 0} 258 + fallback={<p class="text-sm text-gray-500 dark:text-gray-400">no knots available.</p>} 259 + > 260 + <For each={knotsQuery.data}> 261 + {(knot) => ( 262 + <div class="flex items-center leading-tight"> 263 + <input 264 + type="radio" 265 + name="knot" 266 + value={knot} 267 + checked={selectedKnot() === knot} 268 + onChange={() => setSelectedKnot(knot)} 269 + class="mr-2 cursor-pointer" 270 + id={`fork-knot-${knot}`} 271 + required 272 + /> 273 + <label for={`fork-knot-${knot}`} class="dark:text-white lowercase cursor-pointer"> 274 + {knot} 275 + </label> 276 + </div> 277 + )} 278 + </For> 279 + </Show> 280 + </Show> 281 + </div> 282 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 283 + a knot hosts repository data and handles git operations. you can also{' '} 284 + <A href="/settings/knots" class="underline"> 285 + register your own knot 286 + </A> 287 + . 288 + </p> 289 + </div> 290 + </div> 291 + </div> 292 + </div> 293 + 294 + <div class="untangled-repo-form-submit flex justify-end"> 295 + <button 296 + type="submit" 297 + disabled={saving() || !selectedKnot()} 298 + class="btn-create flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 299 + > 300 + <Show when={saving()} fallback={<GitFork class="w-4 h-4" />}> 301 + <LoaderCircle class="w-4 h-4 animate-spin" /> 302 + </Show> 303 + fork repo 304 + </button> 305 + </div> 306 + </form> 307 + </div> 308 + </div> 309 + </Show> 310 + )} 311 + </Match> 312 + </Switch> 313 + ); 314 + };
+16 -16
src/pages/repo/new.tsx
··· 134 134 <Show 135 135 when={auth.currentDid()} 136 136 fallback={ 137 - <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded p-6 relative flex gap-2 items-center dark:text-white"> 137 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded p-4 relative flex gap-2 items-center dark:text-white"> 138 138 please log in to create a new repository. 139 139 </div> 140 140 } 141 141 > 142 142 <div class="grid grid-cols-12 dark:text-white"> 143 - <div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4"> 143 + <div class="col-span-full md:col-start-3 md:col-span-8 px-4 py-1 mb-2"> 144 144 <h1 class="text-xl font-bold dark:text-white mb-1">create a new repository</h1> 145 - <p class="text-gray-600 dark:text-gray-400 mb-1"> 145 + <p class="text-gray-600 dark:text-gray-400"> 146 146 repositories contain a project's files and version history. all repositories are publicly accessible. 147 147 </p> 148 148 </div> 149 149 150 - <div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10 border border-gray-200 dark:border-gray-700"> 150 + <div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-4 md:px-6 border border-gray-200 dark:border-gray-700"> 151 151 <form onSubmit={handleSubmit}> 152 - {error() && <p class="text-red-500 dark:text-red-400 text-sm mb-4">{error()}</p>} 152 + {error() && <p class="text-red-500 dark:text-red-400 text-sm mb-3">{error()}</p>} 153 153 154 154 {/* Step 1 */} 155 155 <div class="untangled-timeline-step untangled-timeline-step-1"> ··· 159 159 </div> 160 160 </div> 161 161 162 - <div class="flex-1 pb-12"> 162 + <div class="flex-1 pb-6 pt-1"> 163 163 <h2 class="text-lg font-medium dark:text-white">general</h2> 164 - <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">basic repository information.</div> 164 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-3">basic repository information.</div> 165 165 166 - <div class="space-y-4"> 166 + <div class="space-y-3"> 167 167 {/* Repository Name */} 168 168 <div> 169 169 <label class="block text-sm font-bold dark:text-white mb-1"> ··· 191 191 </div> 192 192 193 193 {/* Description */} 194 - <div> 194 + <div class="pt-2"> 195 195 <label for="description" class="block text-sm font-bold dark:text-white mb-1"> 196 196 DESCRIPTION 197 197 </label> ··· 221 221 </div> 222 222 </div> 223 223 224 - <div class="flex-1"> 224 + <div class="flex-1 pt-1"> 225 225 <h2 class="text-lg font-medium dark:text-white">configuration</h2> 226 - <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">repository settings and hosting.</div> 226 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-3">repository settings and hosting.</div> 227 227 228 - <div class="space-y-4"> 228 + <div class="space-y-3"> 229 229 {/* Default Branch */} 230 230 <div> 231 231 <label for="branch" class="block text-sm font-bold dark:text-white mb-1"> ··· 246 246 </div> 247 247 248 248 {/* Knot Selection */} 249 - <div> 249 + <div class="pt-2"> 250 250 <label class="block text-sm font-bold dark:text-white mb-1"> 251 251 SELECT A KNOT 252 252 </label> 253 - <div class="w-full space-y-2 py-1"> 253 + <div class="w-full space-y-1 py-0"> 254 254 <Show 255 255 when={!knotsQuery.isLoading} 256 256 fallback={<div class="text-sm text-gray-500 dark:text-gray-400">loading knots...</div>} ··· 261 261 > 262 262 <For each={knotsQuery.data}> 263 263 {(knot) => ( 264 - <div class="flex items-center"> 264 + <div class="flex items-center leading-tight"> 265 265 <input 266 266 type="radio" 267 267 name="domain" ··· 293 293 </div> 294 294 </div> 295 295 296 - <div class="mt-8 flex justify-end"> 296 + <div class="untangled-repo-form-submit flex justify-end"> 297 297 <button 298 298 type="submit" 299 299 disabled={saving() || !selectedKnot()}
+3 -5
src/pages/repo/shared.tsx
··· 342 342 </Show> 343 343 </div> 344 344 <div class="untangled-repo-action"> 345 - <a 346 - href={`https://tangled.org/${repo().owner.handle}/${repo().slug}/fork`} 347 - target="_blank" 348 - rel="noreferrer" 345 + <A 346 + href={`/${repo().owner.handle}/${repo().slug}/fork`} 349 347 class="untangled-repo-action-main" 350 348 > 351 349 <GitFork class="size-4" /> 352 350 <span>fork</span> 353 - </a> 351 + </A> 354 352 <Show 355 353 when={forkCountQuery.isLoading} 356 354 fallback={
+2 -1
src/routes.tsx
··· 6 6 BlobPage, 7 7 CommitPage, 8 8 CommitsPage, 9 + ForkRepoPage, 9 10 IssuePage, 10 11 IssuesPage, 11 12 NewIssuePage, ··· 66 67 <Route path="/commits" component={CommitsPage} /> 67 68 <Route path="/commits/:ref" component={CommitsPage} /> 68 69 <Route path="/commit/:ref" component={CommitPage} /> 70 + <Route path="/fork" component={ForkRepoPage} /> 69 71 <Route path="/blob/:ref/*path" component={BlobPage} /> 70 72 <Route path="/tree/:ref/*path" component={RepoCodePage} /> 71 73 </Route> ··· 73 75 <Route path="*404" component={NotFoundPage} /> 74 76 </Router> 75 77 ); 76 -