From 4d141e86130a3feffc3f1d1054f45332c8f14ceb Mon Sep 17 00:00:00 2001 From: dawn <90008@gaze.systems> Date: Sat, 30 May 2026 21:32:23 +0300 Subject: [PATCH] new repo --- public/oauth-client-metadata.json | 2 +- src/index.css | 61 ++++++ src/layout.tsx | 6 +- src/lib/api/repos.ts | 111 ++++++++++- src/pages/repo.tsx | 1 + src/pages/repo/new.tsx | 313 ++++++++++++++++++++++++++++++ src/routes.tsx | 2 + 7 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 src/pages/repo/new.tsx diff --git a/public/oauth-client-metadata.json b/public/oauth-client-metadata.json index c5e5174..427bb36 100644 --- a/public/oauth-client-metadata.json +++ b/public/oauth-client-metadata.json @@ -3,7 +3,7 @@ "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", diff --git a/src/index.css b/src/index.css index 9feec65..438d8df 100644 --- a/src/index.css +++ b/src/index.css @@ -3812,4 +3812,65 @@ details[open] > .untangled-language-bar { } } +/* 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 */ + } +} + diff --git a/src/layout.tsx b/src/layout.tsx index 370ebad..f0871fd 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -1,5 +1,5 @@ import 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'; @@ -311,6 +311,10 @@ const NewMenu: Component = () => {
+ + + new repository + new string diff --git a/src/lib/api/repos.ts b/src/lib/api/repos.ts index a42fd52..fbc784d 100644 --- a/src/lib/api/repos.ts +++ b/src/lib/api/repos.ts @@ -13,13 +13,14 @@ import { } 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 { @@ -674,3 +675,111 @@ export const getRepoForkCount = async (repo: RepoContext): Promise => { return 0; } }; + +export const listUserKnots = async (actorIdentifier: string | ResolvedActor): Promise => { + 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 => { + 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; + } +}; diff --git a/src/pages/repo.tsx b/src/pages/repo.tsx index 5f16460..141954a 100644 --- a/src/pages/repo.tsx +++ b/src/pages/repo.tsx @@ -2,3 +2,4 @@ export { 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'; diff --git a/src/pages/repo/new.tsx b/src/pages/repo/new.tsx new file mode 100644 index 0000000..73eaa06 --- /dev/null +++ b/src/pages/repo/new.tsx @@ -0,0 +1,313 @@ +import { 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(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 ( + + 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()}

} + + {/* Step 1 */} +
+
+
+ 1 +
+
+ +
+

general

+
basic repository information.
+ +
+ {/* Repository Name */} +
+ +
+ + 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" + /> +
+

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

+
+ + {/* Description */} +
+ + 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..." + /> +

+ optional. a short description to help others understand what your project does (max 140 characters). +

+
+
+
+
+ + {/* Step 2 */} +
+
+ +
+ +
+ +
+
+ + ); +}; diff --git a/src/routes.tsx b/src/routes.tsx index 6421e88..52611dd 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -10,6 +10,7 @@ import { IssuesPage, NewIssuePage, NewPullPage, + NewRepoPage, PullPage, PullsPage, RepoCodePage, @@ -31,6 +32,7 @@ export const AppRoutes: Component = () => ( + -- 2.51.2