index.tsx (11.51 KB)
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import {
  BookOpenIcon,
  CheckIcon,
  CodeIcon,
  CopyIcon,
  FileIcon,
  FolderIcon,
  TerminalIcon,
} from "lucide-react";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
import { z } from "zod";
import { getRepoByOwnerAndNameOpts } from "@/api/repos";
import { getBlobQueryOptions, getTreeQueryOptions } from "@/api/tree";
import { NotFoundComponent } from "@/components/404-components";
import { BranchSelector } from "@/components/branch-selector";
import { components } from "@/components/md-components";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";

const searchSchema = z.object({
  ref: z.string().optional(),
});

export const Route = createFileRoute("/$owner/$repo/_layout/")({
  component: RouteComponent,
  notFoundComponent: NotFoundComponent,
  validateSearch: searchSchema,
  loaderDeps: ({ search }) => ({
    ref: search.ref,
  }),
  loader: async ({ params, context: { queryClient }, deps, location }) => {
    await queryClient.ensureQueryData(
      getTreeQueryOptions({
        owner: params.owner,
        repo: params.repo,
        ref: deps.ref,
        path: "",
      })
    );

    return {
      url: location.url.toString(),
    };
  },
  pendingComponent: IndexPendingComponent,
});

function IndexPendingComponent() {
  return (
    <div className="py-6">
      <div className="divide-y overflow-hidden rounded-lg border">
        {[1, 2, 3, 4, 5].map((i) => (
          <div className="flex items-center gap-3 p-3" key={i}>
            <Skeleton className="size-4" />
            <Skeleton className="h-4 flex-1" />
          </div>
        ))}
      </div>
    </div>
  );
}

function RouteComponent() {
  const params = Route.useParams();
  const search = Route.useSearch();
  const { owner, repo } = params;
  const { ref } = search;

  const navigate = useNavigate();
  const data = Route.useLoaderData();

  const { data: tree } = useSuspenseQuery(
    getTreeQueryOptions({
      owner,
      repo,
      ref,
      path: "",
    })
  );

  const { data: repository } = useSuspenseQuery(
    getRepoByOwnerAndNameOpts({
      owner,
      name: repo,
    })
  );

  const url = new URL(data.url);
  const repoUrl = `${url.origin ?? window.location.origin}/${owner}/${repo}.git`;

  // If tree is empty, show instructions
  if (tree.length === 0) {
    return (
      <EmptyRepositoryInstructions
        isPrivate={repository.isPrivate}
        owner={owner}
        repo={repo}
      />
    );
  }

  // Sort tree: directories first, then files
  const sortedTree = [...tree].sort((a, b) => {
    const aIsDir = a.type === "tree";
    const bIsDir = b.type === "tree";

    if (aIsDir !== bIsDir) {
      return aIsDir ? -1 : 1;
    }

    return a.path.localeCompare(b.path);
  });

  // Find README file (case-insensitive)
  const readmeEntry = tree.find((entry) =>
    /^readme(\.(md|mdx|markdown|txt))?$/i.test(entry.path)
  );

  return (
    <div className="space-y-6">
      {repository.description && (
        <p className="text-muted-foreground">{repository.description}</p>
      )}
      <div className="flex items-center justify-between gap-4">
        <BranchSelector
          onBranchChange={(newBranch) => {
            navigate({
              to: ".",
              search: { ref: newBranch },
            });
          }}
          owner={owner}
          repo={repo}
          selectedBranch={ref}
        />
        <CloneButton repoUrl={repoUrl} />
      </div>
      <div className="divide-y overflow-hidden rounded-lg border">
        {sortedTree.map((entry) => {
          const isDirectory = entry.type === "tree";
          const newPath = entry.path;

          return (
            <Link
              className="grid grid-cols-[300px_minmax(0,1fr)_auto] items-center gap-4 p-3 transition-colors hover:bg-muted/50"
              key={entry.oid}
              params={{ owner, repo }}
              search={{
                ref,
                path: newPath,
              }}
              to={isDirectory ? "/$owner/$repo/tree" : "/$owner/$repo/blob"}
            >
              {/* Left: File/Directory Name */}
              <div className="flex min-w-0 items-center gap-3">
                {isDirectory ? (
                  <FolderIcon className="size-4 shrink-0 text-blue-400" />
                ) : (
                  <FileIcon className="size-4 shrink-0 text-muted-foreground" />
                )}
                <span className="truncate text-sm">{entry.path}</span>
              </div>

              {entry.lastCommit && (
                <span
                  className="truncate text-muted-foreground text-sm"
                  title={entry.lastCommit.commit.message}
                >
                  {entry.lastCommit.commit.message.split("\n")[0]}
                </span>
              )}
              {!entry.lastCommit && <span />}

              {entry.lastCommit && (
                <span className="shrink-0 text-muted-foreground text-sm">
                  {formatDate(entry.lastCommit.commit.committer.timestamp)}
                </span>
              )}
              {!entry.lastCommit && <span />}
            </Link>
          );
        })}
      </div>

      {readmeEntry && (
        <ReadmeViewer
          filepath={readmeEntry.path}
          owner={owner}
          ref={ref}
          repo={repo}
        />
      )}
    </div>
  );
}

function CloneButton({ repoUrl }: { repoUrl: string }) {
  const [copied, setCopied] = useState(false);

  const copyToClipboard = async () => {
    await navigator.clipboard.writeText(repoUrl);
    setCopied(true);
    toast.success("Repository URL copied to clipboard");
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button variant="outline">
          <CodeIcon className="size-4" />
          Code
          <span className="text-[9px]"></span>
        </Button>
      </PopoverTrigger>
      <PopoverContent align="end" className="w-100">
        <div className="space-y-3">
          <div>
            <h4 className="font-semibold text-sm">Clone this repository</h4>
            <p className="mt-1 text-muted-foreground text-xs">
              Use Git to clone this repository to your local machine.
            </p>
          </div>
          <div className="space-y-2">
            <div className="font-medium text-xs">HTTPS</div>
            <div className="flex items-center gap-2">
              <div className="flex-1 overflow-x-auto rounded-md border bg-muted px-3 py-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
                <code className="whitespace-nowrap text-xs">{repoUrl}</code>
              </div>
              <Button onClick={copyToClipboard} size="icon" variant="outline">
                {copied ? (
                  <CheckIcon className="size-4" />
                ) : (
                  <CopyIcon className="size-4" />
                )}
              </Button>
            </div>
          </div>
        </div>
      </PopoverContent>
    </Popover>
  );
}

function ReadmeViewer({
  owner,
  repo,
  ref,
  filepath,
}: {
  owner: string;
  repo: string;
  ref?: string;
  filepath: string;
}) {
  const { data: blob } = useSuspenseQuery(
    getBlobQueryOptions({
      owner,
      repo,
      ref,
      filepath,
    })
  );

  if (!blob || blob.isBinary) {
    return null;
  }

  const content = decodeURIComponent(
    atob(blob.content)
      .split("")
      .map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`)
      .join("")
  );

  const isMarkdown = filepath.toLowerCase().match(/\.(md|mdx|markdown)$/);

  return (
    <div className="overflow-hidden rounded-lg border">
      <div className="flex items-center gap-2 border-b bg-card px-4 py-3">
        <BookOpenIcon className="size-4" />
        <span className="font-semibold text-sm">{filepath}</span>
      </div>
      <div className="p-6">
        {isMarkdown ? (
          <ReactMarkdown
            components={components}
            rehypePlugins={[rehypeRaw]}
            remarkPlugins={[remarkGfm]}
          >
            {content}
          </ReactMarkdown>
        ) : (
          <pre className="overflow-x-auto font-mono text-sm">{content}</pre>
        )}
      </div>
    </div>
  );
}

function EmptyRepositoryInstructions({
  owner,
  repo,
  isPrivate,
}: {
  owner: string;
  repo: string;
  isPrivate: boolean;
}) {
  const data = Route.useLoaderData();
  const url = new URL(data.url);

  const repoUrl = `${url.origin ?? window.location.origin}/${owner}/${repo}.git`;

  return (
    <div>
      <div className="mx-auto space-y-6">
        <div className="text-center">
          <h2 className="font-bold text-xl">This repository is empty.</h2>
          <p className="mt-2 text-muted-foreground">
            Get started by pushing an existing repository or creating a new one.
          </p>
        </div>

        <Card>
          <CardContent className="text-sm">
            <CardTitle className="mb-3">Note</CardTitle>
            <div className="leading-relaxed">
              {isPrivate ? (
                <p>
                  This is a <strong>private repository</strong>. You'll need a
                  Personal Access Token (PAT) to clone and push changes.
                </p>
              ) : (
                <p>
                  To push changes to this repository, you'll need a Personal
                  Access Token (PAT).
                </p>
              )}{" "}
              <p>
                Create one in{" "}
                <Link
                  className="font-medium underline underline-offset-4"
                  to="/settings"
                >
                  Settings &gt; Personal Access Tokens
                </Link>
              </p>
            </div>
          </CardContent>
        </Card>

        <Card>
          <CardHeader>
            <CardTitle className="flex items-center gap-2">
              <TerminalIcon className="size-5" />
              Create a new repository on the command line
            </CardTitle>
          </CardHeader>
          <CardContent>
            <div className="space-y-3">
              <CodeBlock
                code={`echo "# ${repo}" >> README.md
git init
git add README.md
git commit -m "first commit"
git branch -M main
git remote add origin ${repoUrl}
git push -u origin main`}
              />
            </div>
          </CardContent>
        </Card>

        <Card>
          <CardHeader>
            <CardTitle className="flex items-center gap-2">
              <TerminalIcon className="size-4" />
              Push an existing repository from the command line
            </CardTitle>
          </CardHeader>
          <CardContent>
            <CodeBlock
              code={`git remote add origin ${repoUrl}
git branch -M main
git push -u origin main`}
            />
          </CardContent>
        </Card>
      </div>
    </div>
  );
}

function CodeBlock({ code }: { code: string }) {
  return (
    <pre>
      <code className="text-sm leading-relaxed">{code}</code>
    </pre>
  );
}

function formatDate(timestamp: number): string {
  const date = new Date(timestamp * 1000);
  return formatDistanceToNow(date, { addSuffix: true });
}