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
108 changes: 89 additions & 19 deletions src/src/components/islands/InstallPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { InstallOption } from '~/lib/install';
import type { InstallOption, InstallTab } from '~/lib/install';

import { useState } from 'preact/hooks';

function CopyBlock({ label, detail, code }: InstallOption) {
function CodeBlock({ code }: { code: string }) {
const [copied, setCopied] = useState(false);

const copy = async () => {
Expand All @@ -17,38 +17,108 @@ function CopyBlock({ label, detail, code }: InstallOption) {
}
};

return (
<div class="border-border bg-code-bg flex items-start justify-between gap-3 rounded-lg border px-4 py-3">
<pre class="min-w-0 flex-1 overflow-x-auto text-sm leading-relaxed">
<code>{code}</code>
</pre>
<button
type="button"
onClick={copy}
class="border-border bg-surface hover:border-brand/40 shrink-0 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
>
{copied ? 'Copied ✓' : 'Copy'}
</button>
</div>
);
}

function CopyBlock({ label, detail, code }: InstallOption) {
return (
<div>
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h3 class="text-foreground text-sm font-semibold">{label}</h3>
{detail && <p class="text-muted text-xs">{detail}</p>}
</div>
<div class="border-border bg-code-bg mt-2 flex items-start justify-between gap-3 rounded-lg border px-4 py-3">
<pre class="min-w-0 flex-1 overflow-x-auto text-sm leading-relaxed">
<code>{code}</code>
</pre>
<button
type="button"
onClick={copy}
class="border-border bg-surface hover:border-brand/40 shrink-0 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
>
{copied ? 'Copied ✓' : 'Copy'}
</button>
<div class="mt-2">
<CodeBlock code={code} />
</div>
</div>
);
}

interface Props {
options: InstallOption[];
tabs: InstallTab[];
}

export default function InstallPanel({ options }: Props) {
export default function InstallPanel({ tabs }: Props) {
const [active, setActive] = useState(0);
if (tabs.length === 0) {
return null;
}
const tab = tabs[Math.min(active, tabs.length - 1)];
if (!tab) {
return null;
}

const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
return;
}
event.preventDefault();
const direction = event.key === 'ArrowRight' ? 1 : -1;
const next = (active + direction + tabs.length) % tabs.length;
setActive(next);
document.getElementById(`install-tab-${next}`)?.focus();
};

return (
<div class="mt-4 space-y-4">
{options.map((option) => (
<CopyBlock key={`${option.label}-${option.code}`} {...option} />
))}
<div class="mt-4">
<div
role="tablist"
aria-label="Install options"
onKeyDown={onKeyDown}
tabIndex={-1}
class="border-border flex flex-wrap gap-x-1 border-b"
>
{tabs.map((tabOption, index) => (
<button
type="button"
role="tab"
id={`install-tab-${index}`}
key={tabOption.label}
aria-selected={active === index}
aria-controls={`install-panel-${index}`}
tabIndex={active === index ? 0 : -1}
onClick={() => setActive(index)}
class={[
'-mb-px border-b-2 px-3 py-2 text-sm font-medium transition-colors',
active === index
? 'border-brand text-brand-emphasis'
: 'border-transparent text-muted hover:border-border hover:text-foreground',
].join(' ')}
>
{tabOption.label}
</button>
))}
</div>
<div
role="tabpanel"
id={`install-panel-${active}`}
aria-labelledby={`install-tab-${active}`}
class="mt-4 space-y-4"
>
{tab.snippets.length > 1 ? (
tab.snippets.map((snippet) => (
<CopyBlock key={`${snippet.label}-${snippet.code}`} {...snippet} />
))
) : (
<div>
{tab.detail && <p class="text-muted mb-2 text-xs">{tab.detail}</p>}
<CodeBlock code={tab.snippets[0]?.code ?? ''} />
</div>
)}
</div>
</div>
);
}
142 changes: 105 additions & 37 deletions src/src/lib/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,26 @@ import type { ResolvedProject } from './manifest/load';
/** How a project's primary NuGet package is consumed. */
export type InstallKind = 'nuget' | 'msbuild-sdk' | 'dotnet-tool';

/** A single copyable install snippet. */
export interface InstallOption {
/** Short name for the install target, e.g. ".NET CLI". */
/** Short name for the snippet, e.g. "Directory.Packages.props". */
label: string;
/** Optional one-line hint shown under the label. */
detail?: string;
/** Copyable install snippet (may span multiple lines). */
code: string;
}

/** A tab in the Install guide, grouping one or more related snippets. */
export interface InstallTab {
/** Short name for the install method, e.g. "CPM". */
label: string;
/** Optional one-line hint shown for the whole tab. */
detail?: string;
/** One or more copyable snippets for this method. */
snippets: InstallOption[];
}

/** Resolve the install kind for a project, defaulting to a regular NuGet package. */
export function installKindFor(project: Pick<ResolvedProject, 'install'>): InstallKind {
return project.install;
Expand All @@ -25,98 +36,155 @@ function withVersion(version: string | null): string {
return version ?? VERSION_PLACEHOLDER;
}

function nugetOptions(packageId: string, version: string | null): InstallOption[] {
function nugetTabs(packageId: string, version: string | null): InstallTab[] {
return [
{
label: '.NET CLI',
code: `dotnet add package ${packageId}`,
snippets: [
{
label: '.NET CLI',
code: `dotnet add package ${packageId}`,
},
],
},
{
label: 'CPM — Directory.Packages.props',
detail: 'Add to your central package file.',
code: `<PackageVersion Include="${packageId}" Version="${withVersion(version)}" />`,
},
{
label: 'CPM — Project file',
detail: 'The version is resolved from central package management.',
code: `<PackageReference Include="${packageId}" />`,
label: 'CPM',
detail: 'Central Package Management',
snippets: [
{
label: 'Directory.Packages.props',
detail: 'Add to your central package file.',
code: `<PackageVersion Include="${packageId}" Version="${withVersion(version)}" />`,
},
{
label: 'Project file',
detail: 'The version is resolved from central package management.',
code: `<PackageReference Include="${packageId}" />`,
},
],
},
{
label: 'PackageReference',
code: `<PackageReference Include="${packageId}" Version="${withVersion(version)}" />`,
snippets: [
{
label: 'PackageReference',
code: `<PackageReference Include="${packageId}" Version="${withVersion(version)}" />`,
},
],
},
{
label: 'File-Based Apps',
detail: 'Reference a package from a file-based app.',
code: `#:package ${packageId}@${withVersion(version)}`,
snippets: [
{
label: 'File-Based Apps',
code: `#:package ${packageId}@${withVersion(version)}`,
},
],
},
{
label: 'Script & interactive',
detail: 'Load the package in C# scripts and notebooks.',
code: `#r "nuget: ${packageId}, ${withVersion(version)}"`,
snippets: [
{
label: 'Script & interactive',
code: `#r "nuget: ${packageId}, ${withVersion(version)}"`,
},
],
},
{
label: 'PMC (NuGet\\Install-Package.ps1)',
label: 'PMC',
detail: 'Run from the Package Manager Console.',
code: `Install-Package ${packageId} -Version ${withVersion(version)}`,
snippets: [
{
label: 'PMC',
code: `Install-Package ${packageId} -Version ${withVersion(version)}`,
},
],
},
];
}

function msbuildSdkOptions(packageId: string, version: string | null): InstallOption[] {
function msbuildSdkTabs(packageId: string, version: string | null): InstallTab[] {
return [
{
label: 'global.json',
detail: 'Pin the SDK for the whole repository.',
code: `{\n "msbuild-sdks": {\n "${packageId}": "${withVersion(version)}"\n }\n}`,
snippets: [
{
label: 'global.json',
code: `{\n "msbuild-sdks": {\n "${packageId}": "${withVersion(version)}"\n }\n}`,
},
],
},
{
label: 'SDK',
detail: 'Reference the SDK from an individual project file.',
code: `<Sdk Name="${packageId}" Version="${withVersion(version)}" />`,
snippets: [
{
label: 'SDK',
code: `<Sdk Name="${packageId}" Version="${withVersion(version)}" />`,
},
],
},
{
label: 'File-Based Apps',
detail: 'Reference the SDK from a file-based app.',
code: `#:sdk ${packageId}@${withVersion(version)}`,
snippets: [
{
label: 'File-Based Apps',
code: `#:sdk ${packageId}@${withVersion(version)}`,
},
],
},
];
}

function dotnetToolOptions(packageId: string): InstallOption[] {
function dotnetToolTabs(packageId: string): InstallTab[] {
return [
{
label: '.NET CLI (Global)',
code: `dotnet tool install --global ${packageId}`,
},
{
label: '.NET CLI (Local)',
detail: 'Create a local tool manifest first.',
code: 'dotnet new tool-manifest',
label: 'Global tool',
snippets: [
{
label: 'Global tool',
code: `dotnet tool install --global ${packageId}`,
},
],
},
{
label: '.NET CLI (Local)',
detail: 'Install the tool into the local manifest.',
code: `dotnet tool install ${packageId}`,
label: 'Local tool',
detail: 'Install into a local tool manifest.',
snippets: [
{
label: 'Tool manifest',
detail: 'Create a local tool manifest first.',
code: 'dotnet new tool-manifest',
},
{
label: 'Install',
detail: 'Install the tool into the local manifest.',
code: `dotnet tool install ${packageId}`,
},
],
},
];
}

/**
* Build the ordered list of install options for a package of the given kind.
* Build the ordered list of install tabs for a package of the given kind.
* The order matches the canonical display order on the project page.
*/
export function installOptions(
export function installTabs(
kind: InstallKind,
packageId: string,
version: string | null,
): InstallOption[] {
): InstallTab[] {
switch (kind) {
case 'msbuild-sdk':
return msbuildSdkOptions(packageId, version);
return msbuildSdkTabs(packageId, version);
case 'dotnet-tool':
return dotnetToolOptions(packageId);
return dotnetToolTabs(packageId);
case 'nuget':
return nugetOptions(packageId, version);
return nugetTabs(packageId, version);
}
}
8 changes: 4 additions & 4 deletions src/src/pages/projects/[project].astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import SiteLayout from '~/layouts/SiteLayout.astro';
import InstallPanel from '~/components/islands/InstallPanel.tsx';
import StatusBadge from '~/components/StatusBadge.astro';
import { buildBadge, nugetDownloadsBadge, nugetVersionBadge, actionsUrl } from '~/lib/badges';
import { installKindFor, installOptions } from '~/lib/install';
import { installKindFor, installTabs } from '~/lib/install';
import { loadProjects } from '~/lib/manifest/load';
import { projectRepoEnrichment } from '~/lib/releases/repo';
import { projectReleaseSummary } from '~/lib/releases/transform';
Expand Down Expand Up @@ -44,9 +44,9 @@ const targetFrameworks = project.targetFrameworks ?? [];
const primaryRelease = primaryPackage
? releases.packages.find((pkg) => pkg.packageId === primaryPackage.id)
: undefined;
const installSnippets =
const installTabsOptions =
primaryPackage && project.status !== 'archived'
? installOptions(
? installTabs(
installKindFor(project),
primaryPackage.id,
primaryRelease?.latestStable ?? primaryRelease?.latestPrerelease ?? null,
Expand Down Expand Up @@ -281,7 +281,7 @@ const installSnippets =
primaryPackage && project.status !== 'archived' && (
<section>
<h2 class="text-xl font-semibold">Install</h2>
<InstallPanel client:idle options={installSnippets} />
<InstallPanel client:idle tabs={installTabsOptions} />
</section>
)
}
Expand Down
Loading