diff --git a/src/renderer/src/components/plugins/page/PluginDetailInfo.tsx b/src/renderer/src/components/plugins/page/PluginDetailInfo.tsx index 2f957806316..a58b5c9e70e 100644 --- a/src/renderer/src/components/plugins/page/PluginDetailInfo.tsx +++ b/src/renderer/src/components/plugins/page/PluginDetailInfo.tsx @@ -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} ) @@ -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 ( ) diff --git a/src/renderer/src/components/plugins/page/PluginDetailPage.test.tsx b/src/renderer/src/components/plugins/page/PluginDetailPage.test.tsx index d0d4ef0cc33..f8b8c52937c 100644 --- a/src/renderer/src/components/plugins/page/PluginDetailPage.test.tsx +++ b/src/renderer/src/components/plugins/page/PluginDetailPage.test.tsx @@ -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 () => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4f693241d72..c5be60ca8a1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 32abd9fe725..5263bc2075e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 5fb807720f7..122e43db890 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 86aa57b4c4d..1efa0d1e99e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12539,7 +12539,8 @@ "license": "ライセンス", "whatsNew": "新機能", "hashCopied": "コンテンツハッシュをコピーしました", - "hashCopyFailed": "ハッシュをコピーできませんでした。" + "hashCopyFailed": "ハッシュをコピーできませんでした。", + "readme": "{{value0}} で README を読む" }, "PluginListEmptyStates": { "noFilterMatches": "これらのフィルターに一致するプラグインはありません", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 977b90cc6d8..7e4df94f90b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12539,7 +12539,8 @@ "license": "라이선스", "whatsNew": "새로운 기능", "hashCopied": "콘텐츠 해시를 복사했습니다", - "hashCopyFailed": "해시를 복사할 수 없습니다." + "hashCopyFailed": "해시를 복사할 수 없습니다.", + "readme": "{{value0}}에서 README 읽기" }, "PluginListEmptyStates": { "noFilterMatches": "이 필터와 일치하는 플러그인이 없습니다", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d1a0ff339b8..311435fe5b1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12572,7 +12572,8 @@ "license": "许可证", "whatsNew": "新功能", "hashCopied": "已复制内容哈希", - "hashCopyFailed": "无法复制哈希。" + "hashCopyFailed": "无法复制哈希。", + "readme": "在 {{value0}} 上阅读 README" }, "PluginListEmptyStates": { "noFilterMatches": "没有与这些筛选条件匹配的插件", diff --git a/src/shared/plugins/catalog/plugin-repository-link.test.ts b/src/shared/plugins/catalog/plugin-repository-link.test.ts index 247b6e54769..9a723a6724e 100644 --- a/src/shared/plugins/catalog/plugin-repository-link.test.ts +++ b/src/shared/plugins/catalog/plugin-repository-link.test.ts @@ -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() + }) +}) diff --git a/src/shared/plugins/catalog/plugin-repository-link.ts b/src/shared/plugins/catalog/plugin-repository-link.ts index a349a0ac8fa..75c9c71c478 100644 --- a/src/shared/plugins/catalog/plugin-repository-link.ts +++ b/src/shared/plugins/catalog/plugin-repository-link.ts @@ -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` +}