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`
+}