~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
847 lines · 29.0 KB Raw History
import { useQuery } from '@tanstack/react-query';
import {
  CheckIcon,
  ChevronRightIcon,
  CircleDotIcon,
  CopyIcon,
  FileIcon,
  FolderIcon,
  GitCommitHorizontalIcon,
  HistoryIcon,
  LockIcon,
  RotateCwIcon,
  SettingsIcon,
  XIcon,
} from 'lucide-react';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { ListRow } from '#/components/molecules/list-row.jsx';
import { SelectField } from '#/components/molecules/select-field.jsx';
import { TextField } from '#/components/molecules/text-field.jsx';
import { CommitLogSkeleton } from '#/components/organisms/commit-log-skeleton.jsx';
import { FileContentSkeleton } from '#/components/organisms/file-content-skeleton.jsx';
import { FileTreeSkeleton } from '#/components/organisms/file-tree-skeleton.jsx';
import { RepoPageSkeleton } from '#/components/organisms/repo-page-skeleton.jsx';
import { get, post, xrpcGet, xrpcPost } from '#/lib/api.js';
import { bytes, timeAgo } from '#/lib/format.js';
import { queryKeys, useApiMutation } from '#/lib/query.js';
import { SetupInstructions } from '#/pages/git-new-repo.jsx';

/** "Name <email>" without the email; the ident itself as the fallback. */
function authorName(ident) {
  const angle = ident.indexOf(' <');
  return angle === -1 ? ident : ident.slice(0, angle);
}

/**
 * The commit history at a ref, read from the bundle chain's commit objects.
 */
function LogView({ name, refName, space }) {
  const { data, error } = useQuery({
    queryKey: queryKeys.gitLog({ repo: name, ref: refName, space }),
    queryFn: () =>
      xrpcGet('dev.pdsjs.git.listCommits', { repo: name, ref: refName, space }),
  });
  // A server without the file browser does not serve the method at all.
  if (error?.status === 501) {
    return (
      <Notice>
        File browsing is not available on this server build, so neither is the
        history.
      </Notice>
    );
  }
  if (error) return <Notice variant="error">{error.message}</Notice>;
  if (!data) return <CommitLogSkeleton />;
  return (
    <div className="rounded-2xl border border-border bg-card px-2 py-1">
      {data.commits.map((commit) => (
        <ListRow
          key={commit.sha}
          variant="plain"
          media={<GitCommitHorizontalIcon className="size-4 text-faint" />}
          title={
            <span className="text-[13.5px]">
              {commit.message.split('\n')[0]}
            </span>
          }
          description={`${authorName(commit.author)} · ${timeAgo(new Date(commit.committedAt).toISOString())}`}
          actions={
            <span className="shrink-0 font-mono text-[12px] text-muted-foreground">
              {commit.sha.slice(0, 7)}
            </span>
          }
        />
      ))}
      {data.truncated && (
        <div className="px-3 py-3 text-center text-[12px] text-faint">
          Showing the most recent {data.commits.length} commits.
        </div>
      )}
    </div>
  );
}

/** How each check status reads, and the colour it reads in. */
const CHECK_LOOKS = {
  success: { label: 'passed', className: 'text-success', Icon: CheckIcon },
  failure: { label: 'failed', className: 'text-destructive', Icon: XIcon },
  error: { label: 'errored', className: 'text-destructive', Icon: XIcon },
  running: {
    label: 'running',
    className: 'text-muted-foreground',
    Icon: CircleDotIcon,
  },
};

/**
 * The latest check on the ref being viewed, and the button that asks for
 * another. Checks are published by another account, so an absent one means the
 * runner has not reported yet. That is not an error worth showing, but the
 * card still appears, because a repository with a runner and no check is
 * exactly the one someone wants to start a run on.
 *
 * A repository in a space has no public clone URL, so the shipped runner
 * cannot check it and the button is absent there.
 */
function CheckStatus({ name, space, refName }) {
  const [asked, setAsked] = useState(false);
  const { data } = useQuery({
    queryKey: queryKeys.gitChecks({ repo: name, space }),
    queryFn: () => xrpcGet('dev.pdsjs.git.listChecks', { repo: name, space }),
  });
  const action = useApiMutation([queryKeys.gitChecks({ repo: name, space })]);
  if (!data?.runner) return null;

  const checks = (data.checks ?? [])
    .filter((entry) => entry.ref === `refs/heads/${refName}`)
    .sort((a, b) => (a.workflow ?? 'ci').localeCompare(b.workflow ?? 'ci'));

  const rerun = () =>
    action.run(async () => {
      await xrpcPost('dev.pdsjs.git.requestCheck', {
        name,
        ref: `refs/heads/${refName}`,
      });
      setAsked(true);
    });

  return (
    <div className="flex flex-col gap-2 rounded-2xl border border-border bg-card px-5 py-3">
      {(checks.length ? checks : [null]).map((check, index) => {
        const look = check
          ? (CHECK_LOOKS[check.status] ?? CHECK_LOOKS.running)
          : null;
        return (
          <div
            key={check?.workflow ?? 'none'}
            className="flex items-center gap-2 text-[13px]"
          >
            {look ? (
              <>
                <look.Icon className={`size-3.5 shrink-0 ${look.className}`} />
                <span className={look.className}>
                  {check.workflow ?? 'ci'} {look.label}
                </span>
                <span className="min-w-0 truncate font-mono text-[12px] text-faint">
                  {check.sha?.slice(0, 8)}
                </span>
                {check.finishedAt && (
                  <span className="text-[12px] text-muted-foreground">
                    {timeAgo(check.finishedAt)}
                  </span>
                )}
              </>
            ) : (
              <span className="text-[13px] text-muted-foreground">
                No check on this branch yet.
              </span>
            )}
            {!space && index === 0 && (
              <span className="ml-auto flex shrink-0 items-center gap-2">
                {asked && !action.busy && (
                  <span className="text-[12px] text-muted-foreground">
                    Asked. The runner picks this up from the firehose.
                  </span>
                )}
                <Button
                  variant="outline"
                  size="xs"
                  disabled={action.busy}
                  onClick={rerun}
                >
                  <RotateCwIcon />
                  Run again
                </Button>
              </span>
            )}
          </div>
        );
      })}
      {action.error && <Notice variant="error">{action.error}</Notice>}
    </div>
  );
}

/** What one linked package is keyed by: the pair, since names repeat. */
const packageKey = (/** @type {{registry: string, name: string}} */ entry) =>
  `${entry.registry}:${entry.name}`;

/** How each registry reads beside a package name. */
const REGISTRY_LABELS = { npm: 'npm package', oci: 'container image' };

/**
 * Repository settings: description and default branch on the repo record,
 * protected branches, the check runner and the linked packages in the config
 * record.
 */
function SettingsView({ repo, space }) {
  const branches = repo.refs
    .filter((ref) => ref.name.startsWith('refs/heads/'))
    .map((ref) => ref.name.slice('refs/heads/'.length));
  // A protected name whose branch is gone stays listed, so the protection is
  // not silently dropped by saving an unrelated change.
  const knownBranches = [
    ...new Set([...branches, ...(repo.protectedBranches ?? [])]),
  ].sort();

  const [description, setDescription] = useState(repo.description ?? '');
  const [defaultBranch, setDefaultBranch] = useState(repo.defaultBranch ?? '');
  const [protectedBranches, setProtectedBranches] = useState(
    new Set(repo.protectedBranches ?? []),
  );
  const [runner, setRunner] = useState(repo.runner ?? '');
  const [saved, setSaved] = useState(false);
  const action = useApiMutation([queryKeys.gitRepos]);

  // What this account has published, which is what a link may point at. Both
  // reads answer `enabled: false` where the registry is switched off.
  const { data: npm } = useQuery({
    queryKey: queryKeys.npmPackages,
    queryFn: () => get('/npm/packages'),
  });
  const { data: oci } = useQuery({
    queryKey: queryKeys.ociRepos,
    queryFn: () => get('/oci/repos'),
  });
  const linked = repo.packages ?? [];
  const published = [
    ...(npm?.packages ?? []).map((pkg) => ({
      registry: 'npm',
      name: pkg.name,
    })),
    ...(oci?.repos ?? []).map((image) => ({
      registry: 'oci',
      name: image.name,
    })),
  ];
  // A linked package that is no longer published stays listed, so the link is
  // not silently dropped by saving an unrelated change.
  const choices = [
    ...published,
    ...linked.filter(
      (entry) =>
        !published.some((option) => packageKey(option) === packageKey(entry)),
    ),
  ].sort((a, b) => packageKey(a).localeCompare(packageKey(b)));
  const [packages, setPackages] = useState(new Set(linked.map(packageKey)));

  const togglePackage = (/** @type {string} */ key) => {
    setSaved(false);
    setPackages((current) => {
      const next = new Set(current);
      if (next.has(key)) next.delete(key);
      else next.add(key);
      return next;
    });
  };

  const toggle = (/** @type {string} */ branch) => {
    setSaved(false);
    setProtectedBranches((current) => {
      const next = new Set(current);
      if (next.has(branch)) next.delete(branch);
      else next.add(branch);
      return next;
    });
  };

  const save = async (/** @type {{preventDefault: () => void}} */ event) => {
    event.preventDefault();
    await action.run(async () => {
      await xrpcPost('dev.pdsjs.git.updateSettings', {
        name: repo.name,
        ...(space ? { space } : {}),
        description,
        defaultBranch,
        protectedBranches: [...protectedBranches],
        runner: runner.trim() || null,
        packages: choices.filter((entry) => packages.has(packageKey(entry))),
      });
      setSaved(true);
    });
  };

  return (
    <form
      onSubmit={save}
      className="flex flex-col gap-4 rounded-2xl border border-border bg-card px-5 py-4"
    >
      <TextField
        name="description"
        label="Description"
        placeholder="What this repository is"
        value={description}
        onChange={(/** @type {{target: {value: string}}} */ event) => {
          setSaved(false);
          setDescription(event.target.value);
        }}
      />
      <SelectField
        name="defaultBranch"
        label="Default branch"
        value={defaultBranch}
        onChange={(/** @type {string} */ value) => {
          setSaved(false);
          setDefaultBranch(value);
        }}
        options={
          branches.length > 0
            ? branches.map((branch) => [branch, branch])
            : [['', 'No branches yet']]
        }
      />
      <div className="flex flex-col gap-1.5">
        <span className="text-[13px] text-muted-foreground">
          Protected branches
        </span>
        {knownBranches.length === 0 ? (
          <div className="text-[12px] text-faint">
            Branches appear here after the first push.
          </div>
        ) : (
          <div className="flex flex-col gap-1">
            {knownBranches.map((branch) => (
              <label
                key={branch}
                className="flex items-center gap-2 font-mono text-[13px]"
              >
                <input
                  type="checkbox"
                  checked={protectedBranches.has(branch)}
                  onChange={() => toggle(branch)}
                  className="size-3.5 accent-primary"
                />
                {branch}
                {!branches.includes(branch) && (
                  <span className="font-sans text-[11px] text-faint">
                    (no branch by this name right now)
                  </span>
                )}
              </label>
            ))}
          </div>
        )}
        <div className="text-[12px] text-faint">
          A protected branch refuses force pushes and deletion. The server
          enforces this on your copy of the repository.
        </div>
      </div>
      <div className="flex flex-col gap-1.5">
        <TextField
          name="runner"
          label="Check runner"
          placeholder="did:plc:..."
          value={runner}
          onChange={(/** @type {{target: {value: string}}} */ event) => {
            setSaved(false);
            setRunner(event.target.value);
          }}
        />
        <div className="text-[12px] text-faint">
          The account whose check records this repository trusts. Its results
          appear above the files. Leave it empty for no checks.
        </div>
      </div>
      {(npm?.enabled || oci?.enabled || choices.length > 0) && (
        <div className="flex flex-col gap-1.5">
          <span className="text-[13px] text-muted-foreground">Packages</span>
          {choices.length === 0 ? (
            <div className="text-[12px] text-faint">
              Packages appear here after the first publish to your registry.
            </div>
          ) : (
            <div className="flex flex-col gap-1">
              {choices.map((entry) => {
                const key = packageKey(entry);
                return (
                  <label
                    key={key}
                    className="flex items-center gap-2 font-mono text-[13px]"
                  >
                    <input
                      type="checkbox"
                      checked={packages.has(key)}
                      onChange={() => togglePackage(key)}
                      className="size-3.5 accent-primary"
                    />
                    {entry.name}
                    <span className="font-sans text-[11px] text-faint">
                      {REGISTRY_LABELS[entry.registry] ?? entry.registry}
                    </span>
                  </label>
                );
              })}
            </div>
          )}
          <div className="text-[12px] text-faint">
            What this repository builds. A package record names no repository,
            so this list is what connects the two. The git browser lists a
            linked package beside the repository, with its install command.
          </div>
        </div>
      )}
      {action.error && <Notice variant="error">{action.error}</Notice>}
      <div className="flex items-center gap-3">
        <Button type="submit" disabled={action.busy}>
          Save settings
        </Button>
        {saved && (
          <span className="text-[12px] text-muted-foreground">Saved.</span>
        )}
      </div>
    </form>
  );
}

/** A clone command with a copy button. */
function CloneCommand({ url }) {
  const [copied, setCopied] = useState(false);
  const command = `git clone ${url}`;
  const copy = async () => {
    await navigator.clipboard.writeText(command);
    setCopied(true);
    setTimeout(() => setCopied(false), 1500);
  };
  return (
    <div className="flex min-w-0 items-center gap-2 rounded-lg border border-border bg-background px-3 py-2">
      <code className="min-w-0 flex-1 truncate font-mono text-[12px]">
        {command}
      </code>
      <button
        type="button"
        aria-label="Copy clone command"
        onClick={copy}
        className="shrink-0 text-muted-foreground hover:text-foreground"
      >
        {copied ? (
          <CheckIcon className="size-3.5" />
        ) : (
          <CopyIcon className="size-3.5" />
        )}
      </button>
    </div>
  );
}

/**
 * Who can read a private repository: the space's member list, with add and
 * remove. Shown only to the space's owner, whose PDS is the one that tracks
 * membership.
 */
function MembersCard({ space, ownDid }) {
  const [actor, setActor] = useState('');
  const { data, error } = useQuery({
    queryKey: queryKeys.spaceMembers(space),
    queryFn: () => get(`/spaces/members?space=${encodeURIComponent(space)}`),
  });
  const action = useApiMutation([queryKeys.spaceMembers(space)]);

  const add = async (event) => {
    event.preventDefault();
    const value = actor.trim();
    if (!value) return;
    await action.run(async () => {
      await post('/spaces/members/add', { space, actor: value });
      setActor('');
    });
  };

  return (
    <div className="rounded-2xl border border-border bg-card px-5 py-4">
      <div className="mb-3 flex items-center gap-2">
        <LockIcon className="size-3.5 text-faint" />
        <span className="font-semibold text-[14px]">Members</span>
        <span className="text-[12px] text-muted-foreground">
          can read every repository in this space
        </span>
      </div>
      {error && <Notice variant="error">{error.message}</Notice>}
      <div className="flex flex-col gap-1">
        {(data?.members ?? []).map((member) => (
          <div
            key={member}
            className="flex items-center justify-between gap-2 py-1"
          >
            <span className="min-w-0 truncate font-mono text-[12.5px]">
              {member}
              {member === ownDid ? ' (you)' : ''}
            </span>
            {member !== ownDid && (
              <Button
                variant="ghost"
                size="xs"
                aria-label={`Remove ${member}`}
                disabled={action.busy}
                onClick={() =>
                  action.run(() =>
                    post('/spaces/members/remove', { space, did: member }),
                  )
                }
              >
                <XIcon />
              </Button>
            )}
          </div>
        ))}
      </div>
      <form onSubmit={add} className="mt-3 flex items-center gap-2">
        <input
          value={actor}
          onChange={(event) => setActor(event.target.value)}
          placeholder="handle or DID"
          aria-label="Add a member"
          className="h-9 min-w-0 flex-1 rounded-xl border border-input bg-transparent px-3 font-mono text-[12.5px] outline-none placeholder:text-faint focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
        />
        <Button type="submit" size="sm" disabled={!actor.trim() || action.busy}>
          Add member
        </Button>
      </form>
      {action.error && (
        <div className="mt-2">
          <Notice variant="error">{action.error}</Notice>
        </div>
      )}
    </div>
  );
}

/** The clickable path above the listing: repo root, then each directory. */
function Breadcrumbs({ name, path, refHref, onNavigate }) {
  const segments = path.split('/').filter(Boolean);
  return (
    <div className="flex min-w-0 flex-wrap items-center gap-1 font-mono text-[13px]">
      <Link
        href={refHref('')}
        onNavigate={onNavigate}
        className="text-muted-foreground hover:text-foreground"
      >
        {name}
      </Link>
      {segments.map((segment, i) => {
        const upTo = segments.slice(0, i + 1).join('/');
        const last = i === segments.length - 1;
        return (
          <span key={upTo} className="flex items-center gap-1">
            <ChevronRightIcon className="size-3 text-faint" />
            {last ? (
              <span className="text-foreground">{segment}</span>
            ) : (
              <Link
                href={refHref(upTo)}
                onNavigate={onNavigate}
                className="text-muted-foreground hover:text-foreground"
              >
                {segment}
              </Link>
            )}
          </span>
        );
      })}
    </div>
  );
}

/**
 * A file's content: an image inline, text inline, a notice for other binary
 * or oversized files.
 */
function FileView({ name, refName, path, space }) {
  const query = `repo=${encodeURIComponent(name)}&ref=${encodeURIComponent(refName)}&path=${encodeURIComponent(path)}${space ? `&space=${encodeURIComponent(space)}` : ''}`;
  const { data, error } = useQuery({
    queryKey: queryKeys.gitFile({ repo: name, ref: refName, path, space }),
    queryFn: () =>
      xrpcGet('dev.pdsjs.git.getFile', {
        repo: name,
        ref: refName,
        path,
        space,
      }),
  });
  if (error) return <Notice variant="error">{error.message}</Notice>;
  if (!data) return <FileContentSkeleton />;
  if (data.mediaType) {
    return (
      <div className="rounded-2xl border border-border bg-card">
        <div className="flex items-center justify-between border-border border-b px-5 py-2 text-[12px] text-muted-foreground">
          <span>{data.mediaType}</span>
          <span>{bytes(data.size)}</span>
        </div>
        <div className="flex justify-center px-5 py-6">
          <img
            src={`/xrpc/dev.pdsjs.git.getRaw?${query}`}
            alt={path}
            className="max-h-[70vh] max-w-full rounded-lg"
          />
        </div>
      </div>
    );
  }
  if (data.binary || data.truncated) {
    return (
      <div className="rounded-2xl border border-border bg-card px-5 py-8 text-center text-[13px] text-muted-foreground">
        {data.binary ? 'Binary file' : 'File too large to preview'} (
        {bytes(data.size)})
      </div>
    );
  }
  const lines = data.text.split('\n');
  return (
    <div className="overflow-x-auto rounded-2xl border border-border bg-card">
      <div className="flex items-center justify-between border-border border-b px-5 py-2 text-[12px] text-muted-foreground">
        <span>{lines.length} lines</span>
        <span>{bytes(data.size)}</span>
      </div>
      <pre className="overflow-x-auto px-5 py-4 font-mono text-[12.5px] leading-relaxed">
        {data.text}
      </pre>
    </div>
  );
}

/**
 * One hosted repository: clone commands, a ref picker, and the file tree at
 * the chosen ref, with text previews.
 * @param {{name: string, space?: string, did?: string, refName?: string, path?: string, file?: string, view?: string, onNavigate: (href: string) => void}} props
 */
export function GitRepoPage({
  name,
  space,
  did,
  refName,
  path = '',
  file,
  view,
  onNavigate,
}) {
  const { data: repoData, error: repoError } = useQuery({
    queryKey: queryKeys.gitRepos,
    queryFn: () => xrpcGet('dev.pdsjs.git.listRepos'),
  });
  const repo = repoData?.repos.find(
    (r) => r.name === name && (r.space ?? undefined) === space,
  );
  const activeRef = refName || repo?.defaultBranch || '';

  const empty = repo ? repo.refs.length === 0 : false;
  const { data: tree, error: treeError } = useQuery({
    queryKey: queryKeys.gitTree({ repo: name, ref: activeRef, path, space }),
    queryFn: () =>
      xrpcGet('dev.pdsjs.git.getTree', {
        repo: name,
        ref: activeRef,
        path,
        space,
      }),
    enabled: Boolean(repoData) && !file && !empty,
  });

  if (repoError) return <Notice variant="error">{repoError.message}</Notice>;
  if (!repoData) return <RepoPageSkeleton />;
  if (!repo) {
    return <Notice variant="error">No repository named "{name}".</Notice>;
  }

  const base = `/account/git/${encodeURIComponent(name)}`;
  const href = (
    /** @type {{ref?: string, path?: string, file?: string, view?: string}} */ next,
  ) => {
    const query = new URLSearchParams();
    if (space) query.set('space', space);
    const ref = next.ref ?? activeRef;
    if (ref && ref !== repo.defaultBranch) query.set('ref', ref);
    if (next.path) query.set('path', next.path);
    if (next.file) query.set('file', next.file);
    if (next.view) query.set('view', next.view);
    const qs = query.toString();
    return qs ? `${base}?${qs}` : base;
  };
  const shortRef = (/** @type {string} */ full) =>
    full.replace(/^refs\/(heads|tags)\//, '');

  // A server without the file browser does not serve the method at all.
  const browseDisabled = treeError?.status === 501;
  const dirOf = (/** @type {string} */ p) =>
    p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '';

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-col gap-2">
        <CloneCommand url={repo.cloneUrl} />
        {repo.httpCloneUrl && <CloneCommand url={repo.httpCloneUrl} />}
      </div>

      {repo.space && repo.spaceOwned && (
        <MembersCard space={repo.space} ownDid={did} />
      )}
      {repo.space && !repo.spaceOwned && (
        <Notice>
          A private repository in a space managed by another account. Your copy
          of it is stored here; the member list lives with the space's owner.
        </Notice>
      )}

      {empty ? (
        <div className="rounded-2xl border border-border bg-card px-5 py-4">
          <SetupInstructions
            name={name}
            cloneUrl={repo.cloneUrl}
            authority={repo.cloneUrl.split('/')[2] ?? ''}
            isPrivate={Boolean(repo.space)}
          />
        </div>
      ) : (
        <div className="flex items-center justify-between gap-3">
          <Breadcrumbs
            name={name}
            path={file ?? path}
            refHref={(/** @type {string} */ p) => href({ path: p })}
            onNavigate={onNavigate}
          />
          <div className="flex shrink-0 items-center gap-3">
            {view && (
              <Link
                href={href({ path: '' })}
                onNavigate={onNavigate}
                className="flex items-center gap-1 text-[12px] text-muted-foreground hover:text-foreground"
              >
                <FolderIcon className="size-3.5" /> Files
              </Link>
            )}
            {view !== 'log' && (
              <Link
                href={href({ view: 'log' })}
                onNavigate={onNavigate}
                className="flex items-center gap-1 text-[12px] text-muted-foreground hover:text-foreground"
              >
                <HistoryIcon className="size-3.5" /> History
              </Link>
            )}
            {view !== 'settings' && (!repo.space || repo.spaceOwned) && (
              <Link
                href={href({ view: 'settings' })}
                onNavigate={onNavigate}
                className="flex items-center gap-1 text-[12px] text-muted-foreground hover:text-foreground"
              >
                <SettingsIcon className="size-3.5" /> Settings
              </Link>
            )}
            <select
              aria-label="Ref"
              value={activeRef}
              onChange={(event) =>
                onNavigate(href({ ref: event.target.value, path: '', view }))
              }
              className="rounded-lg border border-border bg-background px-2 py-1 font-mono text-[12px]"
            >
              {repo.refs.map((ref) => (
                <option key={ref.name} value={shortRef(ref.name)}>
                  {shortRef(ref.name)}
                </option>
              ))}
            </select>
          </div>
        </div>
      )}

      {!empty && !view && (
        <CheckStatus name={name} space={space} refName={activeRef} />
      )}

      {empty ? null : view === 'settings' ? (
        <SettingsView
          key={`${repo.name}#${repo.space ?? ''}`}
          repo={repo}
          space={space}
        />
      ) : view === 'log' ? (
        <LogView name={name} refName={activeRef} space={space} />
      ) : file ? (
        <FileView name={name} refName={activeRef} path={file} space={space} />
      ) : browseDisabled ? (
        <Notice>
          File browsing is not available on this server build. The repository is
          still fully clonable with the commands above.
        </Notice>
      ) : treeError ? (
        <Notice variant="error">{treeError.message}</Notice>
      ) : !tree ? (
        <FileTreeSkeleton />
      ) : (
        <div className="rounded-2xl border border-border bg-card px-2 py-1">
          {path && (
            <ListRow
              variant="plain"
              media={<FolderIcon className="size-4 text-faint" />}
              title={<span className="font-mono text-[13px]">..</span>}
              href={href({ path: dirOf(path) })}
              onNavigate={onNavigate}
            />
          )}
          {tree.entries.map((entry) => {
            const entryPath = path ? `${path}/${entry.name}` : entry.name;
            const isDir = entry.type === 'dir';
            return (
              <ListRow
                key={entry.name}
                variant="plain"
                media={
                  isDir ? (
                    <FolderIcon className="size-4 text-faint" />
                  ) : (
                    <FileIcon className="size-4 text-faint" />
                  )
                }
                title={
                  <span className="font-mono text-[13px] font-normal">
                    {entry.name}
                  </span>
                }
                actions={
                  entry.size != null ? (
                    <span className="shrink-0 text-[12px] text-muted-foreground">
                      {bytes(entry.size)}
                    </span>
                  ) : undefined
                }
                href={
                  isDir
                    ? href({ path: entryPath })
                    : href({ path, file: entryPath })
                }
                onNavigate={onNavigate}
              />
            );
          })}
          {tree.entries.length === 0 && (
            <div className="px-3 py-6 text-center text-[13px] text-muted-foreground">
              Empty directory
            </div>
          )}
        </div>
      )}
    </div>
  );
}