Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,11 @@ GITLIB_GITHUB_TOKEN=
# How often (in hours) GitHub community scores are fetched again. They are stored in the database, so this is
# the only time they cost GitHub requests besides the Refresh buttons. 0 turns automatic refreshing off.
#GITLIB_COMMUNITY_REFRESH_HOURS=24
# How often (in hours) the GitHub and GitLab repository lists are checked again. They are stored in the database
# and checked with ETags, so a check that finds nothing new costs no GitHub quota. 0 = only the Refresh button.
#GITLIB_REPO_REFRESH_HOURS=6
# Lists your GitLab projects on the Repositories page (GitLab and Combined tabs). A personal access token with
# the read_api scope also reaches private projects; with only a username, public projects are listed.
GITLIB_GITLAB_TOKEN=
GITLIB_GITLAB_USERNAME=
GITLIB_GITLAB_URL=
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,23 @@ npm run web # dev server at http://localhost:4000
npm run build -w @gitlib/web && npm run preview -w @gitlib/web # production build
```

- The **Repositories** page has four tabs over one list: **Combined** (the default), **Local**, **GitHub**
and **GitLab**. Combined joins each local clone with the GitHub and GitLab repos its remotes point at, so
one row shows where a project lives (clone, GitHub, GitLab) and which of those it is missing; clones of the
same remote collapse into one row. GitHub lists every repo the token's user owns, collaborates on or reaches
through an organization (`GITLIB_GITHUB_TOKEN`; the token needs read access to repositories). GitLab lists
the projects the user is a member of with `GITLIB_GITLAB_TOKEN` (a personal access token with `read_api`),
or only a user's public projects with just `GITLIB_GITLAB_USERNAME`; set `GITLIB_GITLAB_URL` for a
self-hosted instance. Each host's list is stored in the local database (`remote_repo_listings` table), so
the page reads it at once, survives restarts, and never calls GitHub or GitLab per view. The server checks
it in the background when it is missing or older than `GITLIB_REPO_REFRESH_HOURS` (default 6, `0` = only
on the button); a check sends each page's ETag, and a host that answers *Not Modified* costs no GitHub
quota and downloads nothing. **Refresh** fetches both complete lists now. If a check fails (a revoked
token, a rate limit) the stored list stays on screen with the error noted, and automatic retries pause for
half an hour. A host that isn't configured is noted on the page and the rest still works, and clones link
to GitHub or GitLab from their remotes either way. The **Local** tab is the full table (favorites, descriptions, issue
counts, community scores, preview pane); the other tabs list repos with links out, and clones open their
detail page. The tab is kept in the URL (`?source=`).
- Each repo has a detail page (click its name) with Overview, **Issues** and **Pull requests** tabs; the
last two list open GitHub items and say so in a banner. Public repos work as-is; set
`GITLIB_GITHUB_TOKEN` (or `GITHUB_TOKEN`) for private repos and a higher rate limit. The web server
Expand Down
6 changes: 6 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,11 @@ GITLIB_GITHUB_TOKEN=
#GITLIB_COMMUNITY_REFRESH_HOURS=24
# Contribution graph "Local + GitHub & GitLab": GitHub uses the token above (any read-only token works).
# GitLab reads your public contribution calendar; set GITLIB_GITLAB_URL too if you self-host.
# How often (in hours) the GitHub and GitLab repository lists are checked again; 0 = only the Refresh button.
#GITLIB_REPO_REFRESH_HOURS=6
# The GitLab and Combined tabs list your projects. A personal access token with the read_api scope also reaches
# private projects; with only a username, public projects are listed. The GitHub tabs use the token above, which
# needs to be able to read your repositories.
GITLIB_GITLAB_TOKEN=
GITLIB_GITLAB_USERNAME=
GITLIB_GITLAB_URL=
166 changes: 166 additions & 0 deletions apps/web/src/components/catalog-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Link } from "@tanstack/react-router";
import { ExternalLink } from "lucide-react";
import { TechBadge } from "#/components/tech-badge";
import { formatAgo } from "#/lib/format";
import type { CatalogEntry, RemoteLink } from "#/lib/catalog";
import { cn } from "#/lib/utils";
import type { ProviderStatus } from "#/server/catalog";
import type { RepoView } from "#/server/repos";

const chip = "inline-flex h-5 items-center gap-1 border px-1.5 text-xs whitespace-nowrap";

/** Whether gitlib holds a list from this host, so a repo absent from it really is missing there. */
const wasListed = (status: ProviderStatus | undefined): boolean =>
status !== undefined && status.status !== "not-configured" && status.fetchedAt !== null;

function RemoteChip({ label, link, listed }: { label: string; link: RemoteLink | null; listed: boolean }) {
if (!link) {
// Only call a repo "missing" from a host gitlib has actually listed.
return listed ? (
<span className={cn(chip, "border-dashed text-muted-foreground/60")} aria-label={`Not on ${label}`}>
{label}
<span aria-hidden>—</span>
</span>
) : null;
}
const flags = [link.isPrivate && "private", link.isFork && "fork", link.isArchived && "archived"].filter(Boolean);
return (
<a
href={link.url}
target="_blank"
rel="noreferrer"
title={`${link.fullName}${flags.length > 0 ? ` (${flags.join(", ")})` : ""}${link.listed ? "" : " — not in your account's list"}`}
className={cn(chip, "font-medium outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring", !link.listed && "text-muted-foreground")}
>
{label}
<ExternalLink className="size-3 opacity-60" aria-hidden />
</a>
);
}

function flagsOf(entry: CatalogEntry): string[] {
const links = [entry.github, entry.gitlab].filter((link): link is RemoteLink => link !== null);
return [
links.some((link) => link.isPrivate) && "private",
links.some((link) => link.isFork) && "fork",
links.some((link) => link.isArchived) && "archived",
].filter((flag): flag is string => Boolean(flag));
}

/**
* Repositories from every source in one list: where each lives (clone, GitHub, GitLab) is a column, so the
* combined view and the single-source views are the same table over different slices of the catalog.
*/
export function CatalogTable({
entries,
reposByPath,
github,
gitlab,
}: {
entries: CatalogEntry[];
/** Local repos by relativePath, for the stack detected in a clone. */
reposByPath: ReadonlyMap<string, RepoView>;
github: ProviderStatus | undefined;
gitlab: ProviderStatus | undefined;
}) {
return (
<div className="overflow-x-auto border-y">
<table className="w-full min-w-176 table-fixed border-collapse text-sm" data-testid="catalog-table">
<thead className="border-b">
<tr className="h-9 text-left text-xs font-medium tracking-wide text-muted-foreground uppercase">
<th scope="col" className="w-[28%] px-3">Repository</th>
<th scope="col" className="w-[28%] px-3">Description</th>
<th scope="col" className="w-[24%] px-3">Where</th>
<th scope="col" className="w-[10%] px-3">Stack</th>
<th scope="col" className="w-[10%] px-3">Updated</th>
</tr>
</thead>
<tbody className="divide-y">
{entries.map((entry) => {
const local = entry.locals[0];
const technologies = local ? (reposByPath.get(local)?.technologies ?? []) : [];
const external = entry.github?.url ?? entry.gitlab?.url;
const flags = flagsOf(entry);
return (
<tr key={entry.id} data-testid="catalog-row" className="align-top">
<td className="px-3 py-3">
{local ? (
<Link
to="/repos/$"
params={{ _splat: local }}
className="block truncate font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
{entry.name}
</Link>
) : (
<a
href={external}
target="_blank"
rel="noreferrer"
className="block truncate font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
{entry.name}
</a>
)}
<span className="block truncate font-mono text-xs text-muted-foreground" title={entry.fullName}>
{entry.fullName}
</span>
{flags.length > 0 ? (
<span className="block text-xs text-muted-foreground">{flags.join(" · ")}</span>
) : null}
</td>
<td className="px-3 py-3">
{entry.description ? (
<span className="leading-snug">{entry.description}</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-3 py-3">
<div className="flex flex-wrap gap-1.5">
{local ? (
<Link
to="/repos/$"
params={{ _splat: local }}
title={entry.locals.join("\n")}
className={cn(chip, "font-medium outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring")}
>
{entry.locals.length > 1 ? `Local ×${entry.locals.length}` : "Local"}
</Link>
) : (
<span className={cn(chip, "border-dashed text-muted-foreground/60")} aria-label="Not cloned locally">
Local
<span aria-hidden>—</span>
</span>
)}
<RemoteChip label="GitHub" link={entry.github} listed={wasListed(github)} />
<RemoteChip label="GitLab" link={entry.gitlab} listed={wasListed(gitlab)} />
</div>
</td>
<td className="px-3 py-3">
{technologies.length > 0 ? (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{technologies
.filter((tech) => tech.category === "language" || tech.category === "framework")
.slice(0, 3)
.map((tech) => (
<TechBadge key={tech.name} name={tech.name} category={tech.category} />
))}
</div>
) : entry.language ? (
<TechBadge name={entry.language} category="language" />
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-3 py-3 text-xs whitespace-nowrap text-muted-foreground tabular-nums">
{entry.updatedAt ? formatAgo(entry.updatedAt) : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
Loading
Loading