new repo
1 unmodified line234566789102 unmodified lines1 unmodified line "client_id": "https://untangled.wisp.place/oauth-client-metadata.json", "client_name": "untangled", "client_uri": "https://untangled.wisp.place", "redirect_uris": ["https://untangled.wisp.place/oauth/callback"], "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* repo:sh.tangled.feed.comment repo:sh.tangled.string repo:sh.tangled.feed.star blob:*/*", "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.repo repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.create?aud=* repo:sh.tangled.feed.comment repo:sh.tangled.string repo:sh.tangled.feed.star blob:*/*", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", "application_type": "web",2 unmodified lines3810 unmodified linesExpand all3811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763810 unmodified linesExpand all background-image: linear-gradient(to right, transparent calc(1rem - 2px), rgb(161 98 7 / 0.1) calc(1rem - 2px)) !important; }}
/* Timeline Step styling */.untangled-timeline-step { position: relative; padding-left: 2.25rem;}
.untangled-timeline-step::before { content: ''; position: absolute; left: 12px; width: 1px; background-color: #e5e7eb; /* gray-200 */}
@media (prefers-color-scheme: dark) { .untangled-timeline-step::before { background-color: #374151; /* gray-700 */ }}
.untangled-timeline-step-1::before { top: 16px; bottom: 0;}
.untangled-timeline-step-2::before { top: 0; height: 16px;}
.untangled-timeline-circle { position: absolute; left: 0; top: 4px; width: 25px; height: 25px; display: flex; align-items: center; justify-content: center;}
.untangled-timeline-circle-inner { width: 25px; height: 25px; border-radius: 9999px; display: flex; align-items: center; justify-content: center; font-size: 0.875rem; /* text-sm */ font-weight: 500; /* font-medium */ background-color: #e5e7eb; /* bg-gray-200 */ color: #1f2937; /* text-gray-800 */}
@media (prefers-color-scheme: dark) { .untangled-timeline-circle-inner { background-color: #4b5563; /* bg-gray-600 */ color: #f3f4f6; /* text-gray-100 */ }}
1223456303 unmodified linesExpand all310311312313314315316317318319320321143 unmodified linesExpand allimport clsx from 'clsx';import { Bell, BookMarked, LogOut, RotateCcw, Save, Search, Settings, UserRound, Plus, LineSquiggle } from 'lucide-solid';import { Bell, BookMarked, LogOut, RotateCcw, Save, Search, Settings, UserRound, Plus, LineSquiggle, BookPlus } from 'lucide-solid';import { A, useLocation, useNavigate } from '@solidjs/router';import { createQuery, useQueryClient } from '@tanstack/solid-query';import { For, Show, createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js';
303 unmodified linesExpand all <span class="hidden md:inline">new</span> </summary> <div class="untangled-topbar-menu-dropdown"> <div class="untangled-topbar-menu-item-separator"> <A href="/repo/new" class="untangled-topbar-menu-item" onClick={closeMenu}> <BookPlus class="size-4" /> new repository </A> <A href="/strings/new" class="untangled-topbar-menu-item" onClick={closeMenu}> <LineSquiggle class="size-4" /> new string </A>143 unmodified linesExpand all8 unmodified lines9101112131415161713 unmodified lines31323334353637383915 unmodified lines8 unmodified lines IssuePage, IssuesPage, NewIssuePage, NewPullPage, NewRepoPage, PullPage, PullsPage, RepoCodePage,} from './pages/repo';13 unmodified lines <Route path="/strings/new" component={NewStringPage} /> <Route path="/strings/:actor/:rkey" component={StringPage} /> <Route path="/strings/:actor/:rkey/edit" component={EditStringPage} /> <Route path="/strings/:actor/:rkey/raw" component={RawStringPage} /> <Route path="/repo/new" component={NewRepoPage} /> <Route path="/:owner/:repo" component={RepoLayout}> <Route path="/" component={RepoCodePage} /> <Route path="/issues/new" component={NewIssuePage} /> <Route path="/issues/:issueRef" component={IssuePage} />15 unmodified lines12345export { BlobPage, RepoCodePage } from './repo/code';export { CommitPage, CommitsPage } from './repo/commits';export { IssuePage, IssuesPage, NewIssuePage } from './repo/issues';export { NewPullPage, PullPage, PullsPage } from './repo/pulls';export { NewRepoPage } from './repo/new';11 unmodified lines1213141516161718192021222324252627646 unmodified linesExpand all67467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478511 unmodified lines type AppviewRecordView,} from './appview';import { getBacklinksPage } from './backlinks';import type { OAuthUserAgent } from '@atcute/oauth-browser-client';import { getRpc, normalizeServiceUrl, createServiceAuthRpc } from './client';import { getRpc, normalizeServiceUrl, createServiceAuthRpc, createAuthRpc } from './client';import { REPO_COLLECTION } from './constants';import { resolveActor } from './identity';import { maxTimestamp, parseAtUri, timestampFromDate, type RecordCreationResult,} from './records';
export interface RepoContext { owner: ResolvedActor;646 unmodified linesExpand all } catch { return 0; }};
export const listUserKnots = async (actorIdentifier: string | ResolvedActor): Promise<string[]> => { const actor = typeof actorIdentifier === 'string' ? await resolveActor(actorIdentifier) : actorIdentifier; const rpc = getRpc(actor.pds); const records: string[] = []; let cursor: string | undefined;
try { do { const page = await ok( rpc.get('com.atproto.repo.listRecords', { params: { repo: actor.did, collection: 'sh.tangled.knot', limit: 100, cursor, reverse: true, }, }), );
for (const r of page.records) { const rkey = parseAtUri(r.uri).rkey; records.push(rkey); } cursor = page.cursor; } while (cursor); } catch (err) { console.error('Failed to list knots from PDS:', err); }
return records;};
export const createRepo = async ( agent: OAuthUserAgent, input: { name: string; description?: string; defaultBranch: string; knot: string; },): Promise<RecordCreationResult> => { const repoName = input.name.trim(); const cleanedName = repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName; const rkey = cleanedName.toLowerCase();
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, defaultBranch: input.defaultBranch || 'main', }, }), )) 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: repoDid, 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: rkey, record, }, }), );
return { uri: result.uri, rkey: parseAtUri(result.uri).rkey, }; } 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, }), ); } catch (deleteErr) { console.error('Failed to clean up repo on knot:', deleteErr); } throw pdsErr; }};123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313import { A, useNavigate } from '@solidjs/router';import { createQuery, useQueryClient } from '@tanstack/solid-query';import { BookPlus, LoaderCircle } from 'lucide-solid';import { For, Show, createEffect, createSignal, type Component } from 'solid-js';
import { Avatar } from '../../components/common';import { createRepo, listUserKnots, listRepoRecords } from '../../lib/api/repos';import { resolveActor } from '../../lib/api/identity';import { useAuth } from '../../lib/auth';import { getErrorMessage } from '../../lib/repo-utils';
export const validateRepoName = (name: string): string | null => { if (name.length === 0) { return 'repository name cannot be empty'; } if (name.length > 100) { return 'repository name must be 100 characters or fewer'; } if (name.includes('/') || name.includes('\\')) { return 'repository name contains invalid path characters'; } if (name.startsWith('.') || name.endsWith('.')) { return 'repository name contains invalid path sequence'; } if (name.includes('..')) { return 'repository name cannot contain sequential dots'; } const validChars = /^[a-zA-Z0-9\-_.]+$/; if (!validChars.test(name)) { return 'repository name can only contain alphanumeric characters, periods, hyphens, and underscores'; } if (name.toLowerCase() === 'self') { return 'repository name "self" is reserved'; } return null;};
export const NewRepoPage: Component = () => { const auth = useAuth(); const navigate = useNavigate(); const queryClient = useQueryClient();
const [name, setName] = createSignal(''); const [description, setDescription] = createSignal(''); const [branch, setBranch] = createSignal('main'); const [selectedKnot, setSelectedKnot] = createSignal(''); const [saving, setSaving] = createSignal(false); const [error, setError] = createSignal<string | null>(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()!), }));
// Automatically select the knot if there is only one createEffect(() => { const knots = knotsQuery.data; if (knots && knots.length === 1) { setSelectedKnot(knots[0]); } });
const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); const agent = auth.agent(); if (!agent) return;
const repoName = name().trim(); const nameError = validateRepoName(repoName); if (nameError) { setError(nameError); return; }
const knot = selectedKnot(); if (!knot) { setError('please select a knot.'); return; }
setSaving(true); setError(null);
try { // First check if the repository already exists for this user const existingRecords = await queryClient.fetchQuery({ queryKey: ['profile-repos', auth.currentDid()], queryFn: () => listRepoRecords(auth.currentDid()!), });
const cleanedName = repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName; const rkey = cleanedName.toLowerCase(); const exists = existingRecords.some( (r) => r.rkey === rkey || (r.value.name && r.value.name.toLowerCase() === rkey) );
if (exists) { setError(`you already have a repository named "${cleanedName}".`); setSaving(false); return; }
const result = await createRepo(agent, { name: repoName, description: description(), defaultBranch: branch() || 'main', knot: knot, });
// Invalidate repos query for the current user await queryClient.invalidateQueries({ queryKey: ['profile-repos', auth.currentDid()], });
// Navigate to the newly created repository const handle = viewerQuery.data?.handle || auth.currentDid(); navigate(`/${handle}/${result.rkey}`); } catch (err) { setError(getErrorMessage(err)); } finally { setSaving(false); } };
return ( <Show when={auth.currentDid()} fallback={ <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"> please log in to create a new repository. </div> } > <div class="grid grid-cols-12 dark:text-white"> <div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4"> <h1 class="text-xl font-bold dark:text-white mb-1">create a new repository</h1> <p class="text-gray-600 dark:text-gray-400 mb-1"> repositories contain a project's files and version history. all repositories are publicly accessible. </p> </div>
<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"> <form onSubmit={handleSubmit}> {error() && <p class="text-red-500 dark:text-red-400 text-sm mb-4">{error()}</p>}
{/* Step 1 */} <div class="untangled-timeline-step untangled-timeline-step-1"> <div class="untangled-timeline-circle"> <div class="untangled-timeline-circle-inner"> 1 </div> </div>
<div class="flex-1 pb-12"> <h2 class="text-lg font-medium dark:text-white">general</h2> <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">basic repository information.</div>
<div class="space-y-4"> {/* Repository Name */} <div> <label class="block text-sm font-bold dark:text-white mb-1"> REPOSITORY NAME </label> <div class="flex flex-col md:flex-row md:items-center w-full"> <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]"> <Avatar did={auth.currentDid()!} size="size-5" /> <span>{viewerQuery.data?.handle || auth.currentDid()}</span> </div> <input type="text" id="name" name="name" value={name()} onInput={(e) => setName(e.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" /> </div> <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> choose a unique, descriptive name for your repository. use letters, numbers, and hyphens. </p> </div>
{/* Description */} <div> <label for="description" class="block text-sm font-bold dark:text-white mb-1"> DESCRIPTION </label> <input type="text" id="description" name="description" value={description()} onInput={(e) => setDescription(e.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..." /> <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> optional. a short description to help others understand what your project does (max 140 characters). </p> </div> </div> </div> </div>
{/* Step 2 */} <div class="untangled-timeline-step untangled-timeline-step-2"> <div class="untangled-timeline-circle"> <div class="untangled-timeline-circle-inner"> 2 </div> </div>
<div class="flex-1"> <h2 class="text-lg font-medium dark:text-white">configuration</h2> <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">repository settings and hosting.</div>
<div class="space-y-4"> {/* Default Branch */} <div> <label for="branch" class="block text-sm font-bold dark:text-white mb-1"> DEFAULT BRANCH </label> <input type="text" id="branch" name="branch" value={branch()} onInput={(e) => setBranch(e.currentTarget.value)} required 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]" /> <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> the primary branch where development happens. common choices are "main" or "master". </p> </div>
{/* Knot Selection */} <div> <label class="block text-sm font-bold dark:text-white mb-1"> SELECT A KNOT </label> <div class="w-full space-y-2 py-1"> <Show when={!knotsQuery.isLoading} fallback={<div class="text-sm text-gray-500 dark:text-gray-400">loading knots...</div>} > <Show when={knotsQuery.data && knotsQuery.data.length > 0} fallback={<p class="dark:text-white">no knots available.</p>} > <For each={knotsQuery.data}> {(knot) => ( <div class="flex items-center"> <input type="radio" name="domain" value={knot} checked={selectedKnot() === knot} onChange={() => setSelectedKnot(knot)} class="mr-2 cursor-pointer" id={`domain-${knot}`} required /> <label for={`domain-${knot}`} class="dark:text-white lowercase cursor-pointer"> {knot} </label> </div> )} </For> </Show> </Show> </div> <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> a knot hosts repository data and handles git operations. you can also{' '} <A href="/settings/knots" class="underline"> register your own knot </A> . </p> </div> </div> </div> </div>
<div class="mt-8 flex justify-end"> <button type="submit" disabled={saving() || !selectedKnot()} class="btn-create flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" > <Show when={saving()} fallback={<BookPlus class="w-4 h-4" />}> <LoaderCircle class="w-4 h-4 animate-spin" /> </Show> create repository </button> </div> </form> </div> </div> </Show> );};