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
19 changes: 12 additions & 7 deletions apps/web/src/components/catalog-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ export function CatalogTable({
{entries.map((entry) => {
const local = entry.locals[0];
const technologies = local ? (reposByPath.get(local)?.technologies ?? []) : [];
const external = entry.github?.url ?? entry.gitlab?.url;
const remote = entry.github
? ({ provider: "github", link: entry.github } as const)
: entry.gitlab
? ({ provider: "gitlab", link: entry.gitlab } as const)
: null;
const flags = flagsOf(entry);
return (
<tr key={entry.id} data-testid="catalog-row" className="align-top">
Expand All @@ -92,15 +96,16 @@ export function CatalogTable({
>
{entry.name}
</Link>
) : (
<a
href={external}
target="_blank"
rel="noreferrer"
) : remote ? (
<Link
to="/remotes/$provider/$"
params={{ provider: remote.provider, _splat: remote.link.fullName }}
className="block truncate font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
{entry.name}
</a>
</Link>
) : (
<span className="block truncate font-medium">{entry.name}</span>
)}
<span className="block truncate font-mono text-xs text-muted-foreground" title={entry.fullName}>
{entry.fullName}
Expand Down
83 changes: 83 additions & 0 deletions apps/web/src/components/clone-directory-settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useEffect, useId, useState } from "react";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { getCloneDirectory, saveCloneDirectory } from "#/server/catalog";

/** Settings for where "Clone locally" puts repositories. */
export function CloneDirectorySettings() {
const inputId = useId();
const [directory, setDirectory] = useState("");
const [roots, setRoots] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);

useEffect(() => {
getCloneDirectory()
.then((current) => {
setDirectory(current.directory);
setRoots(current.roots);
})
.catch(() => setError("Could not read the current clone directory."));
}, []);

const save = async () => {
setSaving(true);
setError(null);
setSaved(false);
try {
const result = await saveCloneDirectory({ data: { directory } });
if (!result.ok) setError(result.error);
else {
setDirectory(result.directory);
setSaved(true);
}
} catch {
setError("Could not save. See the server log.");
} finally {
setSaving(false);
}
};

return (
<section aria-labelledby="clone-directory-heading" className="flex flex-col gap-4">
<div>
<h2 id="clone-directory-heading" className="text-lg font-medium">
Clone directory
</h2>
<p className="text-sm text-muted-foreground">
Where "Clone locally" puts repositories that are only on GitHub or GitLab. Saved on the server, so it applies in every browser.
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={inputId}>Directory</Label>
<Input
id={inputId}
value={directory}
placeholder="~/git"
onChange={(event) => {
setDirectory(event.target.value);
setSaved(false);
}}
autoComplete="off"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">
Must be inside a scanned root{roots.length > 0 ? ` (${roots.join(", ")})` : ""}, so the clone shows up in your library.
</p>
</div>
<div className="flex items-center gap-3">
<Button type="button" onClick={save} disabled={saving || !directory.trim()}>
{saving ? "Saving…" : "Save"}
</Button>
{saved ? <span className="text-sm text-muted-foreground">Saved.</span> : null}
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
</section>
);
}
23 changes: 23 additions & 0 deletions apps/web/src/lib/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,26 @@ export function matchesQuery(entry: CatalogEntry, query: string | undefined): bo
const haystack = `${entry.name} ${entry.fullName} ${entry.description}`.toLowerCase();
return words.every((word) => haystack.includes(word));
}

/** The entry for a repo that only a hosting service has: listed (or named) by `provider`, with no local clone. */
export function findRemoteOnly(
entries: readonly CatalogEntry[],
provider: RemoteProvider,
fullName: string,
): CatalogEntry | null {
const target = fullName.toLowerCase();
return (
entries.find((entry) => entry.locals.length === 0 && entry[provider]?.fullName.toLowerCase() === target) ?? null
);
}

/** The HTTPS URL `git clone` takes for a repo's landing page. */
export function cloneUrlOf(landingPageUrl: string): string {
return landingPageUrl.replace(/\/+$/, "").replace(/\.git$/i, "") + ".git";
}

/** The folder a clone gets: the repo's own name, which is the last segment of its full name. */
export function cloneFolderName(fullName: string): string | null {
const name = fullName.split("/").filter(Boolean).at(-1)?.replace(/\.git$/i, "");
return name && name !== "." && name !== ".." && !/[\\:\0]/.test(name) ? name : null;
}
21 changes: 21 additions & 0 deletions apps/web/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Route as ProjectsIdRouteImport } from './routes/projects.$id'
import { Route as ReposSplatRouteImport } from './routes/repos.$'
import { Route as ApiDependabotStartRouteImport } from './routes/api.dependabot.start'
import { Route as PlanningIssuesIdRouteImport } from './routes/planning.issues.$id'
import { Route as RemotesProviderSplatRouteImport } from './routes/remotes.$provider.$'

const IndexRoute = IndexRouteImport.update({
id: '/',
Expand Down Expand Up @@ -76,6 +77,11 @@ const PlanningIssuesIdRoute = PlanningIssuesIdRouteImport.update({
path: '/planning/issues/$id',
getParentRoute: () => rootRouteImport,
} as any)
const RemotesProviderSplatRoute = RemotesProviderSplatRouteImport.update({
id: '/remotes/$provider/$',
path: '/remotes/$provider/$',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
Expand All @@ -89,6 +95,7 @@ export interface FileRoutesByFullPath {
'/projects/': typeof ProjectsIndexRoute
'/api/dependabot/start': typeof ApiDependabotStartRoute
'/planning/issues/$id': typeof PlanningIssuesIdRoute
'/remotes/$provider/$': typeof RemotesProviderSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
Expand All @@ -102,6 +109,7 @@ export interface FileRoutesByTo {
'/projects': typeof ProjectsIndexRoute
'/api/dependabot/start': typeof ApiDependabotStartRoute
'/planning/issues/$id': typeof PlanningIssuesIdRoute
'/remotes/$provider/$': typeof RemotesProviderSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
Expand All @@ -116,6 +124,7 @@ export interface FileRoutesById {
'/projects/': typeof ProjectsIndexRoute
'/api/dependabot/start': typeof ApiDependabotStartRoute
'/planning/issues/$id': typeof PlanningIssuesIdRoute
'/remotes/$provider/$': typeof RemotesProviderSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
Expand All @@ -131,6 +140,7 @@ export interface FileRouteTypes {
| '/projects/'
| '/api/dependabot/start'
| '/planning/issues/$id'
| '/remotes/$provider/$'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
Expand All @@ -144,6 +154,7 @@ export interface FileRouteTypes {
| '/projects'
| '/api/dependabot/start'
| '/planning/issues/$id'
| '/remotes/$provider/$'
id:
| '__root__'
| '/'
Expand All @@ -157,6 +168,7 @@ export interface FileRouteTypes {
| '/projects/'
| '/api/dependabot/start'
| '/planning/issues/$id'
| '/remotes/$provider/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
Expand All @@ -171,6 +183,7 @@ export interface RootRouteChildren {
ProjectsIndexRoute: typeof ProjectsIndexRoute
ApiDependabotStartRoute: typeof ApiDependabotStartRoute
PlanningIssuesIdRoute: typeof PlanningIssuesIdRoute
RemotesProviderSplatRoute: typeof RemotesProviderSplatRoute
}

declare module '@tanstack/react-router' {
Expand Down Expand Up @@ -252,6 +265,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlanningIssuesIdRouteImport
parentRoute: typeof rootRouteImport
}
'/remotes/$provider/$': {
id: '/remotes/$provider/$'
path: '/remotes/$provider/$'
fullPath: '/remotes/$provider/$'
preLoaderRoute: typeof RemotesProviderSplatRouteImport
parentRoute: typeof rootRouteImport
}
}
}

Expand All @@ -267,6 +287,7 @@ const rootRouteChildren: RootRouteChildren = {
ProjectsIndexRoute: ProjectsIndexRoute,
ApiDependabotStartRoute: ApiDependabotStartRoute,
PlanningIssuesIdRoute: PlanningIssuesIdRoute,
RemotesProviderSplatRoute: RemotesProviderSplatRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
Expand Down
110 changes: 110 additions & 0 deletions apps/web/src/routes/remotes.$provider.$.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Link, createFileRoute, notFound, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Download, ExternalLink, LoaderCircle } from "lucide-react";
import { useState } from "react";
import { TechBadge } from "#/components/tech-badge";
import { Button } from "#/components/ui/button";
import type { RemoteProvider } from "#/lib/catalog";
import { useLibraryState } from "#/lib/library-state";
import { cloneRepo, getRemoteRepo } from "#/server/catalog";

const LABELS: Record<RemoteProvider, string> = { github: "GitHub", gitlab: "GitLab" };

export const Route = createFileRoute("/remotes/$provider/$")({
// The splat holds the repo's full name on the host, which contains slashes.
loader: async ({ params }) => {
const provider = params.provider === "github" || params.provider === "gitlab" ? params.provider : null;
if (!provider) throw notFound();
const entry = await getRemoteRepo({ data: { provider, fullName: params._splat ?? "" } });
// Cloned in the meantime: the local detail page is the one to show.
if (!entry) throw notFound();
return { provider: provider as RemoteProvider, entry };
},
head: ({ loaderData }) => ({ meta: [{ title: `${loaderData?.entry.name ?? "Repository"} · Gitlib` }] }),
notFoundComponent: RemoteNotFound,
component: RemoteRepoDetail,
});

function BackLink() {
const library = useLibraryState();
return (
<Link
to="/"
search={library}
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeft className="size-3.5" aria-hidden /> Repositories
</Link>
);
}

function RemoteNotFound() {
return (
<div className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-4 px-4 py-8 sm:px-6">
<BackLink />
<p className="border-y py-10 text-center text-sm text-muted-foreground">
That repository isn't listed as hosted-only. It may already be cloned, or the listing may have changed; try a refresh.
</p>
</div>
);
}

function RemoteRepoDetail() {
const { provider, entry } = Route.useLoaderData();
const link = entry[provider]!;
const navigate = useNavigate();
const [cloning, setCloning] = useState(false);
const [error, setError] = useState<string | null>(null);
const flags = [link.isPrivate && "private", link.isFork && "fork", link.isArchived && "archived"].filter(Boolean);

async function clone() {
setCloning(true);
setError(null);
try {
const { relativePath } = await cloneRepo({ data: { provider, fullName: link.fullName } });
await navigate({ to: "/repos/$", params: { _splat: relativePath } });
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not clone the repository.");
setCloning(false);
}
}

return (
<div className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-5 px-4 py-8 sm:px-6">
<BackLink />
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="truncate text-2xl font-semibold tracking-tight">{entry.name}</h1>
<p className="truncate font-mono text-xs text-muted-foreground" title={link.fullName}>
{link.fullName}
</p>
{entry.language ? (
<div className="mt-2">
<TechBadge name={entry.language} category="language" />
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<a href={link.url} target="_blank" rel="noreferrer">
<ExternalLink aria-hidden /> Open on {LABELS[provider]}
</a>
</Button>
<Button size="sm" onClick={clone} disabled={cloning}>
{cloning ? <LoaderCircle className="animate-spin" aria-hidden /> : <Download aria-hidden />}
{cloning ? "Cloning…" : "Clone locally"}
</Button>
</div>
</header>
{error ? (
<p role="alert" className="text-sm whitespace-pre-wrap text-destructive">
{error}
</p>
) : null}
<p className="text-sm text-muted-foreground">
This repository is only on {LABELS[provider]}
{flags.length > 0 ? ` (${flags.join(", ")})` : ""}. Cloning puts a copy in the clone directory set on the Settings page.
</p>
{entry.description ? <p className="text-sm leading-relaxed">{entry.description}</p> : null}
</div>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/routes/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { Check } from "lucide-react";
import { CloneDirectorySettings } from "#/components/clone-directory-settings";
import { OllamaSettings } from "#/components/ollama-settings";
import { cn } from "#/lib/utils";
import { OPTIONAL_REPO_TABS, useRepoTabs } from "#/lib/repo-tabs";
Expand Down Expand Up @@ -64,6 +65,8 @@ function Settings() {
</ul>
</section>

<CloneDirectorySettings />

<OllamaSettings />
</div>
);
Expand Down
Loading
Loading