From 25710187d81cbce487e3ae2edc6e78e73c7bc6ce Mon Sep 17 00:00:00 2001 From: dawn <90008@gaze.systems> Date: Sun, 31 May 2026 18:04:13 +0300 Subject: [PATCH] fork page --- src/index.css | 16 +- src/lib/api.ts | 1 + src/lib/api/repos.ts | 110 +++++++++++-- src/pages/repo.tsx | 1 + src/pages/repo/fork.tsx | 314 ++++++++++++++++++++++++++++++++++++++ src/pages/repo/new.tsx | 32 ++-- src/pages/repo/shared.tsx | 8 +- src/routes.tsx | 3 +- 8 files changed, 447 insertions(+), 38 deletions(-) create mode 100644 src/pages/repo/fork.tsx diff --git a/src/index.css b/src/index.css index 233e32a..126560b 100644 --- a/src/index.css +++ b/src/index.css @@ -3986,10 +3986,22 @@ details[open] > .untangled-language-bar { } } +.untangled-fork-step .untangled-timeline-circle { + top: 4px; +} + +.untangled-fork-step-title { + min-height: 25px; + display: flex; + align-items: center; +} + +.untangled-repo-form-submit { + margin-top: 1rem; +} + /* Checkbox specific overrides to prevent extra layout padding */ .untangled-checkbox { padding: 0 !important; margin: 0 !important; } - - diff --git a/src/lib/api.ts b/src/lib/api.ts index f4a115a..1479a1d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -34,6 +34,7 @@ export { listRepoRecords, compareFork, createHiddenRef, + forkRepo, getRepoForkCount, } from './api/repos'; export { diff --git a/src/lib/api/repos.ts b/src/lib/api/repos.ts index 291fde3..f667416 100644 --- a/src/lib/api/repos.ts +++ b/src/lib/api/repos.ts @@ -749,6 +749,34 @@ export const listUserKnots = async (actorIdentifier: string | ResolvedActor): Pr return records; }; +const cleanedRepoName = (name: string): string => { + const repoName = name.trim(); + return repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName; +}; + +const repoRkeyFromName = (name: string): string => cleanedRepoName(name).toLowerCase(); + +const forkSourceUrl = (sourceRepo: RepoContext): string => + `${normalizeServiceUrl(sourceRepo.knot).replace(/\/+$/, '')}/${sourceRepo.repoDid}`; + +const deleteRepoFromKnot = async ( + agent: OAuthUserAgent, + knot: string, + rkey: string, +): Promise => { + const knotDeleteClient = await createServiceAuthRpc(agent, knot, 'sh.tangled.repo.delete'); + await ok( + knotDeleteClient.post('sh.tangled.repo.delete', { + input: { + did: agent.sub, + name: rkey, + rkey, + }, + as: null, + }), + ); +}; + export const createRepo = async ( agent: OAuthUserAgent, input: { @@ -758,9 +786,8 @@ export const createRepo = async ( knot: string; }, ): Promise => { - const repoName = input.name.trim(); - const cleanedName = repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName; - const rkey = cleanedName.toLowerCase(); + const cleanedName = cleanedRepoName(input.name); + const rkey = repoRkeyFromName(input.name); const knotClient = await createServiceAuthRpc(agent, input.knot, 'sh.tangled.repo.create'); const createResult = (await ok( @@ -806,20 +833,75 @@ export const createRepo = async ( }; } catch (pdsErr) { try { - const knotDeleteClient = await createServiceAuthRpc(agent, input.knot, 'sh.tangled.repo.delete'); - await ok( - knotDeleteClient.post('sh.tangled.repo.delete', { - input: { - did: agent.sub, - name: rkey, - rkey: rkey, - }, - as: null, - }), - ); + await deleteRepoFromKnot(agent, input.knot, rkey); } catch (deleteErr) { console.error('Failed to clean up repo on knot:', deleteErr); } throw pdsErr; } }; + +export const forkRepo = async ( + agent: OAuthUserAgent, + input: { + sourceRepo: RepoContext; + name: string; + description?: string; + knot: string; + }, +): Promise => { + const cleanedName = cleanedRepoName(input.name); + const rkey = repoRkeyFromName(input.name); + + const knotClient = await createServiceAuthRpc(agent, input.knot, 'sh.tangled.repo.create'); + const createResult = (await ok( + knotClient.post('sh.tangled.repo.create', { + input: { + rkey, + name: rkey, + source: forkSourceUrl(input.sourceRepo), + }, + }), + )) as { repoDid?: string }; + + const repoDid = createResult.repoDid; + if (!repoDid) { + throw new Error('Knot failed to mint a repo DID.'); + } + + const pdsRpc = createAuthRpc(agent); + const record = { + $type: 'sh.tangled.repo', + name: cleanedName, + knot: input.knot, + repoDid, + source: input.sourceRepo.repoDid || input.sourceRepo.record.uri, + description: input.description?.trim() || '', + createdAt: new Date().toISOString(), + }; + + try { + const result = await ok( + pdsRpc.post('com.atproto.repo.createRecord', { + input: { + repo: agent.sub, + collection: REPO_COLLECTION, + rkey, + record, + }, + }), + ); + + return { + uri: result.uri, + rkey: parseAtUri(result.uri).rkey, + }; + } catch (pdsErr) { + try { + await deleteRepoFromKnot(agent, input.knot, rkey); + } catch (deleteErr) { + console.error('Failed to clean up fork on knot:', deleteErr); + } + throw pdsErr; + } +}; diff --git a/src/pages/repo.tsx b/src/pages/repo.tsx index 141954a..7bc1882 100644 --- a/src/pages/repo.tsx +++ b/src/pages/repo.tsx @@ -1,5 +1,6 @@ export { BlobPage, RepoCodePage } from './repo/code'; export { CommitPage, CommitsPage } from './repo/commits'; +export { ForkRepoPage } from './repo/fork'; export { IssuePage, IssuesPage, NewIssuePage } from './repo/issues'; export { NewPullPage, PullPage, PullsPage } from './repo/pulls'; export { NewRepoPage } from './repo/new'; diff --git a/src/pages/repo/fork.tsx b/src/pages/repo/fork.tsx new file mode 100644 index 0000000..ea3a74e --- /dev/null +++ b/src/pages/repo/fork.tsx @@ -0,0 +1,314 @@ +import { A, useNavigate } from '@solidjs/router'; +import { createQuery, useQueryClient } from '@tanstack/solid-query'; +import { GitFork, Info, LoaderCircle } from 'lucide-solid'; +import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; + +import { Avatar, ErrorState, LoadingState, cardStyles } from '../../components/common'; +import { resolveActor } from '../../lib/api/identity'; +import { forkRepo, listRepoRecords, listUserKnots } from '../../lib/api/repos'; +import { useAuth } from '../../lib/auth'; +import { getErrorMessage } from '../../lib/repo-utils'; +import { validateRepoName } from './new'; +import { useRepoQuery } from './shared'; + +export const ForkRepoPage: Component = () => { + const auth = useAuth(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const repoQuery = useRepoQuery(); + + const [name, setName] = createSignal(''); + const [description, setDescription] = createSignal(''); + const [selectedKnot, setSelectedKnot] = createSignal(''); + const [saving, setSaving] = createSignal(false); + const [error, setError] = createSignal(null); + const [initializedForRepo, setInitializedForRepo] = createSignal(null); + + const viewerQuery = createQuery(() => ({ + queryKey: ['viewer', auth.currentDid()], + enabled: Boolean(auth.currentDid()), + queryFn: async () => resolveActor(auth.currentDid()!), + })); + + const knotsQuery = createQuery(() => ({ + queryKey: ['knots', auth.currentDid()], + enabled: Boolean(auth.currentDid()), + queryFn: async () => listUserKnots(auth.currentDid()!), + })); + + createEffect(() => { + const repo = repoQuery.data; + if (!repo || initializedForRepo() === repo.repoDid) { + return; + } + + setName(repo.slug); + setDescription(repo.record.value.description ?? ''); + setInitializedForRepo(repo.repoDid); + }); + + createEffect(() => { + const knots = knotsQuery.data; + if (knots && knots.length === 1) { + setSelectedKnot(knots[0]); + } + }); + + const descriptionLength = createMemo(() => description().length); + const viewerLabel = createMemo(() => viewerQuery.data?.handle || auth.currentDid() || ''); + + const handleSubmit = async (event: SubmitEvent) => { + event.preventDefault(); + + const agent = auth.agent(); + const currentDid = auth.currentDid(); + const sourceRepo = repoQuery.data; + if (!agent || !currentDid || !sourceRepo) return; + + const repoName = name().trim(); + const nameError = validateRepoName(repoName); + if (nameError) { + setError(nameError); + return; + } + + if (description().length > 140) { + setError('description must be 140 characters or fewer.'); + return; + } + + const knot = selectedKnot(); + if (!knot) { + setError('please select a knot.'); + return; + } + + setSaving(true); + setError(null); + + try { + const existingRecords = await queryClient.fetchQuery({ + queryKey: ['profile-repos', currentDid], + queryFn: () => listRepoRecords(currentDid), + }); + + const cleanedName = repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName; + const rkey = cleanedName.toLowerCase(); + const exists = existingRecords.some( + (record) => record.rkey.toLowerCase() === rkey || record.value.name?.trim().toLowerCase() === rkey, + ); + + if (exists) { + setError(`you already have a repository named "${cleanedName}".`); + setSaving(false); + return; + } + + const result = await forkRepo(agent, { + sourceRepo, + name: repoName, + description: description(), + knot, + }); + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['profile-repos', currentDid] }), + queryClient.invalidateQueries({ queryKey: ['repo-fork-count', sourceRepo.repoDid] }), + ]); + + navigate(`/${viewerLabel() || currentDid}/${result.rkey}`); + } catch (cause) { + setError(getErrorMessage(cause)); + } finally { + setSaving(false); + } + }; + + return ( + + +
+ +
+
+ +
+ +
+
+ + {(repo) => ( + + please log in to fork this repository. + + } + > +
+ + +
+
+ +

+ a fork is your own copy of this repository. +
+ use it to work independently, then open a pull request back upstream. +

+
+ +
+ {error() &&

{error()}

} + +
+
+
1
+
+ +
+

details

+
basic repository information.
+ +
+
+ +
+ + setName(event.currentTarget.value)} + required + 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]" + placeholder="repository-name" + /> +
+

+ choose a unique, descriptive name for your repository. use letters, numbers, and hyphens. +

+
+ +
+ + setDescription(event.currentTarget.value)} + maxlength="140" + 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]" + placeholder="a brief description of your project..." + /> +

+ optional. a short description to help others understand what your project does ({descriptionLength()}/140 characters). +

+
+
+
+
+ +
+
+
2
+
+ +
+

knot selection

+
repository settings and hosting.
+ +
+
+ +
+ loading knots...
} + > + 0} + fallback={

no knots available.

} + > + + {(knot) => ( +
+ setSelectedKnot(knot)} + class="mr-2 cursor-pointer" + id={`fork-knot-${knot}`} + required + /> + +
+ )} +
+
+ +
+

+ a knot hosts repository data and handles git operations. you can also{' '} + + register your own knot + + . +

+
+
+
+
+ +
+ +
+ +
+ +
+ )} +
+
+ ); +}; diff --git a/src/pages/repo/new.tsx b/src/pages/repo/new.tsx index 73eaa06..075fbc6 100644 --- a/src/pages/repo/new.tsx +++ b/src/pages/repo/new.tsx @@ -134,22 +134,22 @@ export const NewRepoPage: Component = () => { +
please log in to create a new repository.
} >
-
+

create a new repository

-

+

repositories contain a project's files and version history. all repositories are publicly accessible.

-
+
- {error() &&

{error()}

} + {error() &&

{error()}

} {/* Step 1 */}
@@ -159,11 +159,11 @@ export const NewRepoPage: Component = () => {
-
+

general

-
basic repository information.
+
basic repository information.
-
+
{/* Repository Name */}
{/* Description */} -
+
@@ -221,11 +221,11 @@ export const NewRepoPage: Component = () => {
-
+

configuration

-
repository settings and hosting.
+
repository settings and hosting.
-
+
{/* Default Branch */}
{/* Knot Selection */} -
+
-
+
loading knots...
} @@ -261,7 +261,7 @@ export const NewRepoPage: Component = () => { > {(knot) => ( -
+
{
-
+
- fork - + ( + @@ -73,4 +75,3 @@ export const AppRoutes: Component = () => ( ); - -- 2.51.2