import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Input } from '#/components/atoms/input.jsx';
import { Label } from '#/components/atoms/label.jsx';
import { Textarea } from '#/components/atoms/textarea.jsx';
import { OptionRow } from '#/components/molecules/option-row.jsx';
import { PageNotice } from '#/components/molecules/page-notice.jsx';
import { RefSelect } from '#/components/molecules/ref-select.jsx';
import { Screen } from '#/components/molecules/screen.jsx';
import { SettingsPanel } from '#/components/molecules/settings-panel.jsx';
import {
account,
branchRefs,
defaultRef,
loadRepos,
repoRecord,
shortRef,
} from '#/lib/git.js';
import { resolveIdentity } from '#/lib/identity.js';
import { useNavigate } from '#/lib/navigation.jsx';
import { useSession } from '#/lib/session.jsx';
import {
deleteRepo,
packageKey,
publishedPackages,
renameRepo,
updateSettings,
} from '#/lib/settings.js';
/** How each registry reads beside a package name. */
const REGISTRY_LABELS = { npm: 'npm package', oci: 'container image' };
/** The branch names one repository record holds. */
const branchesOf = (record) =>
branchRefs(record).map((ref) => shortRef(ref.name));
/**
* What the repository is, and which branch a visitor lands on. Both are
* fields of the repository record, which every push rewrites, so the server
* reads the record and patches the two rather than replacing it.
*/
function GeneralPanel({ record, write }) {
const branches = branchesOf(record);
// The branch a visitor lands on today: the one the record names, or main
// where it names none. A repository whose named branch is gone falls back
// to the first it has, so the picker never shows a branch that is not
// there.
const landing = branches.includes(defaultRef(record))
? defaultRef(record)
: (branches[0] ?? '');
const [description, setDescription] = useState(record.description ?? '');
const [defaultBranch, setDefaultBranch] = useState(landing);
const save = () =>
write({
description: description.trim(),
// An empty repository has no branch to name, and sending an empty one
// would clear the branch a first push is about to create.
...(branches.length > 0 ? { defaultBranch } : {}),
});
return (
{branches.length === 0 ? (
Branches appear here after the first push.
) : (
)}
);
}
/**
* Which branches the write guard defends. The rules are in the config record
* beside the repository, which no push touches, so a protection outlives
* every push it refuses.
*/
function BranchPanel({ record, write }) {
const branches = branchesOf(record);
const listed = record.protectedBranches ?? [];
// A protected name whose branch is gone stays listed, so saving an
// unrelated change does not drop the protection with it.
const known = [...new Set([...branches, ...listed])].sort();
const [chosen, setChosen] = useState(new Set(listed));
const toggle = (/** @type {string} */ branch) =>
setChosen((current) => {
const next = new Set(current);
if (next.has(branch)) next.delete(branch);
else next.add(branch);
return next;
});
const wanted = [...chosen].sort();
return (
write({ protectedBranches: wanted })}
>
{known.length === 0 ? (
Branches appear here after the first push.
) : (
{known.map((branch) => (
toggle(branch)}
name={branch}
note={
branches.includes(branch) ? undefined : 'no branch by this name'
}
/>
))}
)}
);
}
/**
* The account whose check records this repository trusts. A check is a
* statement in its author's own repo, so anybody may publish one about a
* public repository; naming one account is how a reader decides whose runs
* are this repository's.
*/
function RunnerPanel({ record, write }) {
const [runner, setRunner] = useState(record.runner ?? '');
const save = async () => {
const wanted = runner.trim();
if (!wanted) return write({ runner: null });
// A handle is what an owner knows their runner by, and the record holds
// the DID a handle can be moved away from.
const { did } = await resolveIdentity(wanted);
await write({ runner: did });
};
return (
A handle or a DID. The runner's results open in the Checks section.
);
}
/**
* What this repository builds. A registry record names no repository and
* atproto has no reverse index, so this list is the only thing connecting
* the two.
*/
function PackagesPanel({ record, write }) {
const linked = record.packages ?? [];
const { data: published } = useQuery({
queryKey: ['published-packages'],
queryFn: publishedPackages,
staleTime: Number.POSITIVE_INFINITY,
});
// A linked package that is no longer published stays listed, so saving an
// unrelated change does not drop the link with it.
const choices = [
...(published ?? []),
...linked.filter(
(entry) =>
!(published ?? []).some(
(option) => packageKey(option) === packageKey(entry),
),
),
].sort((a, b) => packageKey(a).localeCompare(packageKey(b)));
const [chosen, setChosen] = useState(new Set(linked.map(packageKey)));
const toggle = (/** @type {string} */ key) =>
setChosen((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const wanted = choices.filter((entry) => chosen.has(packageKey(entry)));
return (
write({ packages: wanted })}
>
{choices.length === 0 ? (
Packages appear here after the first publish to this account's
registries.
)}
);
}
/**
* Renaming the repository. The record key is its identity, so the issues,
* pull requests and checks are unmoved. What moves is the address, which is
* why this says so rather than leaving it to be discovered by a push that
* fails.
*
* A repository in a space keeps its name: the space is what an address
* resolves to and is keyed by that name.
*/
function RenamePanel({ repo, space, session, queryClient }) {
const navigate = useNavigate();
const [name, setName] = useState(repo);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const rename = async (/** @type {{preventDefault: () => void}} */ event) => {
event.preventDefault();
setBusy(true);
setError('');
try {
await renameRepo(session, repo, name.trim());
await loadRepos();
await queryClient.invalidateQueries();
navigate(`/${encodeURIComponent(name.trim())}/settings`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
};
if (space) {
return (
{}}
>
{repo}
);
}
return (
);
}
/**
* Deleting the repository, which no other panel can be mistaken for: its own
* surround, its own colour, and the name typed out before the button works.
*
* What goes is stated rather than implied. A reader who wants the history
* afterwards has their clone or nothing.
*/
function DangerPanel({ repo, space, session, queryClient }) {
const navigate = useNavigate();
const [typed, setTyped] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const remove = async (/** @type {{preventDefault: () => void}} */ event) => {
event.preventDefault();
setBusy(true);
setError('');
try {
await deleteRepo(session, repo, space ?? '');
await loadRepos();
await queryClient.invalidateQueries();
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
};
return (
);
}
/**
* Everything about a repository that its owner sets rather than pushes.
*
* The settings are the owner's, so the section is theirs alone: the tab
* appears for their sign-in and nobody else's, and every write runs on their
* own PDS under their own grant. Each group saves on its own, because the
* server takes one setting per request and leaves the rest as it found them.
*/
export function SettingsPage({ repo, space }) {
const record = repoRecord(repo, space);
const { session } = useSession();
const queryClient = useQueryClient();
const mine = Boolean(session && session.did === account.did);
if (!record) return No such repository.;
if (!mine) {
return (
Settings write into {account.handle}'s own repo, so they need this
account's sign-in rather than permission from anybody else. Sign in as{' '}
{account.handle} from the bar above.
);
}
/** @param {import('#/lib/settings.js').SettingsChange} changes */
const write = async (changes) => {
await updateSettings(
/** @type {import('#/lib/oauth.js').Session} */ (session),
repo,
space ?? '',
changes,
);
// Every screen reads the repository from what the listing put in hand, so
// the save is not finished until that listing is read again.
await loadRepos();
await queryClient.invalidateQueries();
};
return (