Skip to content

Commit 4661f90

Browse files
authored
Add visitor launch tools
Adds visitor-facing launch, starter comparison, and decision shortcut tools.
1 parent 6825bbc commit 4661f90

7 files changed

Lines changed: 312 additions & 6 deletions

test/repo-launch-artifacts.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, test } from "bun:test"
2+
3+
import { buildRepoLaunchArtifacts } from "../web/src/lib/repo-launch-artifacts"
4+
5+
describe("repo launch artifacts", () => {
6+
test("builds web, GitHub, and CLI targets from pasted repository input", () => {
7+
expect(buildRepoLaunchArtifacts("https://github.com/openai/codex")).toEqual({
8+
owner: "openai",
9+
repo: "codex",
10+
fullName: "openai/codex",
11+
canonicalPath: "/openai/codex",
12+
discoforkUrl: "https://discofork.ai/openai/codex",
13+
githubUrl: "https://github.com/openai/codex",
14+
analysisCommand: "discofork analyze openai/codex",
15+
})
16+
})
17+
18+
test("allows previewing against another deployment origin", () => {
19+
expect(buildRepoLaunchArtifacts("openai/codex", "https://preview.discofork.ai/")?.discoforkUrl).toBe(
20+
"https://preview.discofork.ai/openai/codex",
21+
)
22+
})
23+
24+
test("rejects inputs that the shared repo launcher rejects", () => {
25+
expect(buildRepoLaunchArtifacts("https://example.com/openai/codex")).toBeNull()
26+
expect(buildRepoLaunchArtifacts("admin/.env")).toBeNull()
27+
})
28+
})

web/src/app/page.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import Link from "next/link"
22
import { ArrowRight, Compass, GitCompareArrows, Search, Terminal } from "lucide-react"
33

44
import { CopyInstallButton } from "@/components/copy-install-button"
5+
import { DecisionShortcuts } from "@/components/decision-shortcuts"
56
import { LocalWorkspacePanel } from "@/components/local-workspace-panel"
67
import { QueueInput } from "@/components/queue-input"
78
import { RandomDiscoveryButton } from "@/components/random-discovery-button"
89
import { RecentlyViewedWidget } from "@/components/recently-viewed-widget"
10+
import { RepoLaunchBuilder } from "@/components/repo-launch-builder"
911
import { RepoShell } from "@/components/repo-shell"
1012
import { StarterRepoGrid } from "@/components/starter-repo-grid"
1113
import { TrendingRepos } from "@/components/trending-repos"
@@ -196,6 +198,14 @@ export default function HomePage() {
196198
</div>
197199
</section>
198200

201+
<div className="mt-10 sm:mt-14">
202+
<RepoLaunchBuilder />
203+
</div>
204+
205+
<div className="mt-10 sm:mt-14">
206+
<DecisionShortcuts />
207+
</div>
208+
199209
<div className="mt-10 sm:mt-14">
200210
<StarterRepoGrid />
201211
</div>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import Link from "next/link"
2+
import { Activity, AlertTriangle, GitFork, Sparkles, Star } from "lucide-react"
3+
4+
import { buildRepoListHref } from "@/lib/repository-list-query"
5+
6+
const shortcuts = [
7+
{
8+
title: "Fresh analyses",
9+
description: "Open repositories with the newest cached briefs first.",
10+
href: buildRepoListHref(1, "updated", "ready", ""),
11+
icon: Sparkles,
12+
},
13+
{
14+
title: "Active upstreams",
15+
description: "Prioritize projects with recent upstream pushes.",
16+
href: buildRepoListHref(1, "pushed", "ready", ""),
17+
icon: Activity,
18+
},
19+
{
20+
title: "Popular projects",
21+
description: "Start where community signal is strongest.",
22+
href: buildRepoListHref(1, "stars", "ready", ""),
23+
icon: Star,
24+
},
25+
{
26+
title: "Large fork networks",
27+
description: "Find repos where choosing the right fork matters.",
28+
href: buildRepoListHref(1, "forks", "ready", ""),
29+
icon: GitFork,
30+
},
31+
{
32+
title: "Failed analyses",
33+
description: "Check repos that may need another queue attempt.",
34+
href: buildRepoListHref(1, "updated", "failed", ""),
35+
icon: AlertTriangle,
36+
},
37+
]
38+
39+
export function DecisionShortcuts() {
40+
return (
41+
<section className="space-y-4">
42+
<div className="space-y-2">
43+
<div className="font-mono text-xs uppercase tracking-[0.24em] text-muted-foreground">Decision shortcuts</div>
44+
<h2 className="text-xl font-semibold tracking-tight text-foreground">Jump straight to the repo list that answers your question.</h2>
45+
</div>
46+
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5">
47+
{shortcuts.map((shortcut) => {
48+
const Icon = shortcut.icon
49+
50+
return (
51+
<Link
52+
key={shortcut.title}
53+
href={shortcut.href}
54+
className="group rounded-md border border-border bg-card/65 p-4 transition-colors hover:border-primary/50 hover:bg-card"
55+
>
56+
<Icon className="h-4 w-4 text-primary" />
57+
<div className="mt-3 text-sm font-semibold text-foreground">{shortcut.title}</div>
58+
<p className="mt-2 text-xs leading-5 text-muted-foreground">{shortcut.description}</p>
59+
</Link>
60+
)
61+
})}
62+
</div>
63+
</section>
64+
)
65+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"use client"
2+
3+
import { useMemo, useState } from "react"
4+
import Link from "next/link"
5+
import { ArrowRight, Check, Copy, Github, Route, Terminal } from "lucide-react"
6+
7+
import { Badge } from "@/components/ui/badge"
8+
import { buttonVariants } from "@/components/ui/button"
9+
import { buildRepoLaunchArtifacts } from "@/lib/repo-launch-artifacts"
10+
import { cn } from "@/lib/utils"
11+
12+
function CopyAction({ value, label }: { value: string; label: string }) {
13+
const [copied, setCopied] = useState(false)
14+
15+
const handleCopy = async () => {
16+
try {
17+
await navigator.clipboard.writeText(value)
18+
} catch {
19+
const input = document.createElement("input")
20+
input.value = value
21+
document.body.appendChild(input)
22+
input.select()
23+
document.execCommand("copy")
24+
document.body.removeChild(input)
25+
}
26+
27+
setCopied(true)
28+
window.setTimeout(() => setCopied(false), 1800)
29+
}
30+
31+
return (
32+
<button
33+
type="button"
34+
onClick={handleCopy}
35+
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
36+
>
37+
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
38+
{copied ? "Copied" : label}
39+
</button>
40+
)
41+
}
42+
43+
export function RepoLaunchBuilder() {
44+
const [input, setInput] = useState("github.com/openai/codex")
45+
const artifacts = useMemo(() => buildRepoLaunchArtifacts(input), [input])
46+
const hasInput = input.trim().length > 0
47+
48+
return (
49+
<section className="grid gap-5 rounded-[1.25rem] border border-border bg-card/60 p-5 sm:p-6 lg:grid-cols-[0.9fr_1.1fr]">
50+
<div className="space-y-3">
51+
<div className="flex items-center gap-2">
52+
<Route className="h-4 w-4 text-primary" />
53+
<div className="font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">Repo route builder</div>
54+
</div>
55+
<h2 className="text-lg font-semibold text-foreground">Turn any GitHub repo into a Discofork workspace.</h2>
56+
<p className="text-sm leading-7 text-muted-foreground">
57+
Paste a GitHub URL, SSH remote, owner/repo, or Discofork URL. The builder gives you the web route and the local analysis command in one place.
58+
</p>
59+
</div>
60+
61+
<div className="space-y-4">
62+
<div className="space-y-2">
63+
<label htmlFor="repo-launch-builder-input" className="text-xs font-medium text-muted-foreground">
64+
Repository
65+
</label>
66+
<input
67+
id="repo-launch-builder-input"
68+
type="text"
69+
value={input}
70+
onChange={(event) => setInput(event.target.value)}
71+
placeholder="github.com/owner/repo"
72+
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
73+
/>
74+
{!artifacts && hasInput ? <p className="text-xs text-rose-500">Enter a GitHub repository URL or owner/repo name.</p> : null}
75+
</div>
76+
77+
<div className="grid gap-3">
78+
<div className="rounded-md border border-border bg-background/70 p-3">
79+
<div className="flex flex-wrap items-center justify-between gap-2">
80+
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
81+
<Github className="h-4 w-4 text-primary" />
82+
{artifacts?.fullName ?? "Waiting for a repository"}
83+
</div>
84+
{artifacts ? <Badge variant="success">Ready</Badge> : <Badge variant="muted">Preview</Badge>}
85+
</div>
86+
<div className="mt-3 flex flex-wrap gap-2">
87+
{artifacts ? (
88+
<>
89+
<Link href={artifacts.canonicalPath} className={cn(buttonVariants({ variant: "default" }), "h-8 rounded-md px-3 text-xs")}>
90+
Open route
91+
<ArrowRight className="h-3.5 w-3.5" />
92+
</Link>
93+
<CopyAction value={artifacts.discoforkUrl} label="Copy URL" />
94+
<a href={artifacts.githubUrl} target="_blank" rel="noreferrer" className={cn(buttonVariants({ variant: "outline" }), "h-8 rounded-md px-3 text-xs")}>
95+
GitHub
96+
</a>
97+
</>
98+
) : null}
99+
</div>
100+
</div>
101+
102+
<div className="rounded-md border border-border bg-background/70 p-3">
103+
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
104+
<Terminal className="h-3.5 w-3.5" />
105+
Local command
106+
</div>
107+
<div className="mt-2 break-all font-mono text-sm leading-6 text-foreground">
108+
{artifacts?.analysisCommand ?? "discofork analyze owner/repo"}
109+
</div>
110+
{artifacts ? <div className="mt-3"><CopyAction value={artifacts.analysisCommand} label="Copy command" /></div> : null}
111+
</div>
112+
</div>
113+
</div>
114+
</section>
115+
)
116+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"use client"
2+
3+
import { useCallback, useState } from "react"
4+
import { Check, GitCompareArrows } from "lucide-react"
5+
6+
import { Button } from "@/components/ui/button"
7+
import { buildCompareHref, setCompareSelection } from "@/lib/compare"
8+
9+
export function StarterCollectionCompareButton({ repos }: { repos: string[] }) {
10+
const [selected, setSelected] = useState(false)
11+
12+
const handleSelect = useCallback(() => {
13+
setCompareSelection(repos)
14+
setSelected(true)
15+
window.setTimeout(() => setSelected(false), 1800)
16+
}, [repos])
17+
18+
return (
19+
<div className="flex flex-wrap items-center gap-2">
20+
<Button type="button" variant="outline" onClick={handleSelect} className="h-8 gap-1.5 rounded-md px-3 text-xs">
21+
{selected ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <GitCompareArrows className="h-3.5 w-3.5" />}
22+
{selected ? "Compare seeded" : "Compare collection"}
23+
</Button>
24+
<a href={buildCompareHref(repos)} className="text-xs font-medium text-primary transition-colors hover:text-primary/80">
25+
Open compare
26+
</a>
27+
</div>
28+
)
29+
}

web/src/components/starter-repo-grid.tsx

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"use client"
22

3+
import { useMemo, useState } from "react"
34
import Link from "next/link"
45
import { ArrowRight } from "lucide-react"
56

67
import { CompareToggle } from "@/components/compare-toggle"
8+
import { StarterCollectionCompareButton } from "@/components/starter-collection-compare-button"
79
import { Badge } from "@/components/ui/badge"
810
import { buttonVariants } from "@/components/ui/button"
9-
import { getStarterRepoCards, type StarterRepoCard } from "@/lib/starter-repos"
11+
import { getStarterRepoCards, getStarterRepoCollections, type StarterRepoCard } from "@/lib/starter-repos"
1012
import { cn } from "@/lib/utils"
1113

1214
function StarterRepoCardView({ repo }: { repo: StarterRepoCard }) {
@@ -42,14 +44,46 @@ export function StarterRepoGrid({
4244
description?: string
4345
limit?: number
4446
}) {
45-
const repos = getStarterRepoCards(limit)
47+
const collections = useMemo(() => getStarterRepoCollections(), [])
48+
const [activeCollectionSlug, setActiveCollectionSlug] = useState(collections[0]?.slug ?? "")
49+
const activeCollection = collections.find((collection) => collection.slug === activeCollectionSlug) ?? collections[0]
50+
const repos = activeCollection ? activeCollection.repos.slice(0, limit) : getStarterRepoCards(limit)
4651

4752
return (
4853
<section className="space-y-4 rounded-[1.25rem] border border-border bg-card/60 p-5 sm:p-6">
49-
<div className="space-y-2">
50-
<div className="font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">Starter launchpad</div>
51-
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
52-
<p className="text-sm leading-7 text-muted-foreground">{description}</p>
54+
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
55+
<div className="space-y-2">
56+
<div className="font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">Starter launchpad</div>
57+
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
58+
<p className="max-w-3xl text-sm leading-7 text-muted-foreground">{activeCollection?.description ?? description}</p>
59+
</div>
60+
{activeCollection ? <StarterCollectionCompareButton repos={activeCollection.repos.map((repo) => repo.fullName)} /> : null}
61+
</div>
62+
{collections.length > 1 ? (
63+
<div className="flex flex-wrap gap-2">
64+
{collections.map((collection) => (
65+
<button
66+
key={collection.slug}
67+
type="button"
68+
onClick={() => setActiveCollectionSlug(collection.slug)}
69+
aria-pressed={collection.slug === activeCollection?.slug}
70+
className={cn(
71+
"rounded-md border px-3 py-1.5 text-xs font-medium transition-colors",
72+
collection.slug === activeCollection?.slug
73+
? "border-primary/40 bg-primary/10 text-primary"
74+
: "border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground",
75+
)}
76+
>
77+
{collection.label}
78+
</button>
79+
))}
80+
</div>
81+
) : null}
82+
<div className="rounded-md border border-border bg-background/60 p-3">
83+
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
84+
<span className="font-medium text-foreground">{activeCollection?.label ?? "Starter repos"}</span>
85+
<span>{repos.length} repos ready to open or compare</span>
86+
</div>
5387
</div>
5488
<div className="grid gap-3 md:grid-cols-2">
5589
{repos.map((repo) => (
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { parseRepoLauncherInput, type RepoLauncherTarget } from "./repo-launcher"
2+
3+
export type RepoLaunchArtifacts = RepoLauncherTarget & {
4+
discoforkUrl: string
5+
githubUrl: string
6+
analysisCommand: string
7+
}
8+
9+
export function buildRepoLaunchArtifacts(rawInput: string, origin = "https://discofork.ai"): RepoLaunchArtifacts | null {
10+
const target = parseRepoLauncherInput(rawInput)
11+
12+
if (!target) {
13+
return null
14+
}
15+
16+
const normalizedOrigin = origin.replace(/\/+$/, "")
17+
18+
return {
19+
...target,
20+
discoforkUrl: `${normalizedOrigin}${target.canonicalPath}`,
21+
githubUrl: `https://github.com/${target.fullName}`,
22+
analysisCommand: `discofork analyze ${target.fullName}`,
23+
}
24+
}

0 commit comments

Comments
 (0)