feat(plugins): link plugin pages to the README at the pinned release commit (#36527)

This commit is contained in:
Neil
2026-10-30 18:57:53 -07:00
committed by GitHub
parent 2048245a26
commit eff549111c
10 changed files with 94 additions and 11 deletions
@@ -8,7 +8,10 @@ import { DetailSection } from './PluginDetailSections'
import { formatPluginReleaseDate } from '../presentation/plugin-release-date'
import { usePluginRollbackVersion } from '../state/use-plugin-rollback-version'
import { openAppHttpLink } from '@/lib/app-http-link-open'
import { pluginRepositoryPageUrl } from '../../../../../shared/plugins/catalog/plugin-repository-link'
import {
pluginReadmePageUrl,
pluginRepositoryPageUrl
} from '../../../../../shared/plugins/catalog/plugin-repository-link'
function InfoRow({
label,
@@ -33,7 +36,7 @@ function InfoRow({
)
}
function RepositoryLink({ url }: { url: string | null }) {
function ExternalLink({ url, label }: { url: string | null; label: string }) {
if (!url) {
return null
}
@@ -44,7 +47,7 @@ function RepositoryLink({ url }: { url: string | null }) {
className="-ml-2"
onClick={(event) => openAppHttpLink(url, { event })}
>
{translate('auto.components.settings.PluginDetailPage.repository', 'Repository')}
{label}
<ArrowUpRight />
</Button>
)
@@ -69,6 +72,13 @@ export function PluginDetailInfo({
const sourceUrl =
listing?.source.url ?? (plugin?.source?.kind === 'git' ? plugin.source.reference : null)
const repository = sourceUrl ? pluginRepositoryPageUrl(sourceUrl) : null
const readme = listing?.release
? pluginReadmePageUrl({
gitUrl: listing.source.url,
commit: listing.release.commit,
path: listing.source.path
})
: null
return (
<aside className="mt-6">
@@ -129,7 +139,18 @@ export function PluginDetailInfo({
/>
</dl>
<div className="mt-2 flex flex-col items-start">
<RepositoryLink url={repository} />
<ExternalLink
url={repository}
label={translate('auto.components.settings.PluginDetailPage.repository', 'Repository')}
/>
<ExternalLink
url={readme}
label={translate(
'auto.components.settings.PluginDetailInfo.readme',
'Read the README on {{value0}}',
{ value0: readme?.startsWith('https://gitlab.com/') ? 'GitLab' : 'GitHub' }
)}
/>
</div>
</aside>
)
@@ -194,6 +194,13 @@ describe('PluginDetailPage', () => {
)
await act(async () => repository?.click())
expect(window.api.shell.openUrl).toHaveBeenCalledWith('https://github.com/acme/icons')
const readme = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Read the README on GitHub'
)
await act(async () => readme?.click())
expect(window.api.shell.openUrl).toHaveBeenCalledWith(
`https://github.com/acme/icons/tree/${'d'.repeat(40)}#readme`
)
})
it('never claims no access when the plugin could not be read, and retries', async () => {
+2 -1
View File
@@ -13218,7 +13218,8 @@
"license": "License",
"whatsNew": "What’s new",
"hashCopied": "Content hash copied",
"hashCopyFailed": "Couldn't copy the hash."
"hashCopyFailed": "Couldn't copy the hash.",
"readme": "Read the README on {{value0}}"
},
"PluginListEmptyStates": {
"noFilterMatches": "No plugins match these filters",
+2 -1
View File
@@ -12827,7 +12827,8 @@
"license": "Licencia",
"whatsNew": "Novedades",
"hashCopied": "Hash de contenido copiado",
"hashCopyFailed": "No se pudo copiar el hash."
"hashCopyFailed": "No se pudo copiar el hash.",
"readme": "Leer el README en {{value0}}"
},
"PluginListEmptyStates": {
"noFilterMatches": "Ningún plugin coincide con estos filtros",
+2 -1
View File
@@ -13051,7 +13051,8 @@
"license": "Licence",
"whatsNew": "Nouveautés",
"hashCopied": "Empreinte du contenu copiée",
"hashCopyFailed": "Impossible de copier l’empreinte."
"hashCopyFailed": "Impossible de copier l’empreinte.",
"readme": "Lire le README sur {{value0}}"
},
"PluginListEmptyStates": {
"noFilterMatches": "Aucun plugin ne correspond à ces filtres",
+2 -1
View File
@@ -12539,7 +12539,8 @@
"license": "ライセンス",
"whatsNew": "新機能",
"hashCopied": "コンテンツハッシュをコピーしました",
"hashCopyFailed": "ハッシュをコピーできませんでした。"
"hashCopyFailed": "ハッシュをコピーできませんでした。",
"readme": "{{value0}} で README を読む"
},
"PluginListEmptyStates": {
"noFilterMatches": "これらのフィルターに一致するプラグインはありません",
+2 -1
View File
@@ -12539,7 +12539,8 @@
"license": "라이선스",
"whatsNew": "새로운 기능",
"hashCopied": "콘텐츠 해시를 복사했습니다",
"hashCopyFailed": "해시를 복사할 수 없습니다."
"hashCopyFailed": "해시를 복사할 수 없습니다.",
"readme": "{{value0}}에서 README 읽기"
},
"PluginListEmptyStates": {
"noFilterMatches": "이 필터와 일치하는 플러그인이 없습니다",
+2 -1
View File
@@ -12572,7 +12572,8 @@
"license": "许可证",
"whatsNew": "新功能",
"hashCopied": "已复制内容哈希",
"hashCopyFailed": "无法复制哈希。"
"hashCopyFailed": "无法复制哈希。",
"readme": "在 {{value0}} 上阅读 README"
},
"PluginListEmptyStates": {
"noFilterMatches": "没有与这些筛选条件匹配的插件",
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { pluginRepositoryPageUrl } from './plugin-repository-link'
import { pluginReadmePageUrl, pluginRepositoryPageUrl } from './plugin-repository-link'
describe('pluginRepositoryPageUrl', () => {
it('builds the repository page from the https or ssh install URL', () => {
@@ -31,3 +31,31 @@ describe('pluginRepositoryPageUrl', () => {
}
})
})
describe('pluginReadmePageUrl', () => {
const commit = 'a'.repeat(40)
it('links the plugin folder at the pinned commit', () => {
expect(pluginReadmePageUrl({ gitUrl: 'https://github.com/Acme/Plugins.git', commit })).toBe(
`https://github.com/acme/plugins/tree/${commit}#readme`
)
expect(
pluginReadmePageUrl({
gitUrl: 'https://github.com/acme/plugins',
commit,
path: 'themes/gleam'
})
).toBe(`https://github.com/acme/plugins/tree/${commit}/themes/gleam#readme`)
expect(pluginReadmePageUrl({ gitUrl: 'git@gitlab.com:acme/tools/notes.git', commit })).toBe(
`https://gitlab.com/acme/tools/notes/-/tree/${commit}#readme`
)
})
it('refuses unpinned commits, odd paths and unlinkable hosts', () => {
const gitUrl = 'https://github.com/acme/plugins'
expect(pluginReadmePageUrl({ gitUrl, commit: 'main' })).toBeNull()
expect(pluginReadmePageUrl({ gitUrl, commit, path: 'a/../b' })).toBeNull()
expect(pluginReadmePageUrl({ gitUrl, commit, path: 'a/%2e%2e' })).toBeNull()
expect(pluginReadmePageUrl({ gitUrl: 'https://evil.example/acme/x', commit })).toBeNull()
})
})
@@ -17,3 +17,24 @@ export function pluginRepositoryPageUrl(gitUrl: string): string | null {
}
return `https://${identity.host}/${segments.join('/')}`
}
const COMMIT_RE = /^[0-9a-f]{40}$/
/** The plugin's folder at the exact commit the catalog pinned, where the host renders its
* README; built like `pluginRepositoryPageUrl`, so a listing cannot point it elsewhere. */
export function pluginReadmePageUrl(source: {
gitUrl: string
commit: string
path?: string
}): string | null {
const repository = pluginRepositoryPageUrl(source.gitUrl)
if (!repository || !COMMIT_RE.test(source.commit)) {
return null
}
const pathSegments = source.path ? source.path.split('/') : []
if (!pathSegments.every((segment) => PATH_SEGMENT_RE.test(segment))) {
return null
}
const tree = repository.startsWith('https://gitlab.com/') ? '-/tree' : 'tree'
return `${[repository, tree, source.commit, ...pathSegments].join('/')}#readme`
}