Skip to content

Commit 86063ea

Browse files
authored
Merge pull request #11416 from Turbo87/purl
Add Package URL (PURL) display to crate sidebar
2 parents 8b6b57d + 362e3b0 commit 86063ea

File tree

7 files changed

+226
-1
lines changed

7 files changed

+226
-1
lines changed

app/components/crate-sidebar.hbs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,28 @@
66
<div local-class="metadata">
77
<h2 local-class="heading">Metadata</h2>
88

9+
<div local-class="purl" data-test-purl>
10+
{{svg-jar "link"}}
11+
<CopyButton
12+
@copyText={{@version.purl}}
13+
class="button-reset"
14+
local-class="purl-copy-button"
15+
>
16+
<span local-class="purl-text">{{@version.purl}}</span>
17+
<Tooltip local-class="purl-tooltip"><strong>Package URL:</strong> {{@version.purl}} <small>(click to copy)</small></Tooltip>
18+
</CopyButton>
19+
<a
20+
href="https://github.com/package-url/purl-spec"
21+
target="_blank"
22+
rel="noopener noreferrer"
23+
local-class="purl-help-link"
24+
aria-label="Learn more"
25+
>
26+
{{svg-jar "circle-question"}}
27+
<Tooltip @text="Learn more about Package URLs" />
28+
</a>
29+
</div>
30+
931
<time
1032
datetime={{date-format-iso @version.created_at}}
1133
local-class="date"

app/components/crate-sidebar.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { action } from '@ember/object';
12
import { service } from '@ember/service';
23
import Component from '@glimmer/component';
34

@@ -6,6 +7,7 @@ import { didCancel } from 'ember-concurrency';
67
import { simplifyUrl } from './crate-sidebar/link';
78

89
export default class CrateSidebar extends Component {
10+
@service notifications;
911
@service playground;
1012
@service sentry;
1113

@@ -39,4 +41,14 @@ export default class CrateSidebar extends Component {
3941
}
4042
});
4143
}
44+
45+
@action
46+
async copyToClipboard(text) {
47+
try {
48+
await navigator.clipboard.writeText(text);
49+
this.notifications.success('Copied to clipboard!');
50+
} catch {
51+
this.notifications.error('Copy to clipboard failed!');
52+
}
53+
}
4254
}

app/components/crate-sidebar.module.css

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
.msrv,
2323
.edition,
2424
.license,
25-
.bytes {
25+
.bytes,
26+
.purl {
2627
display: flex;
2728
align-items: center;
2829

@@ -52,6 +53,60 @@
5253
font-variant-numeric: tabular-nums;
5354
}
5455

56+
.purl {
57+
align-items: flex-start;
58+
}
59+
60+
.purl-copy-button {
61+
text-align: left;
62+
width: 100%;
63+
min-width: 0;
64+
cursor: pointer;
65+
66+
&:focus {
67+
outline: 2px solid var(--yellow500);
68+
outline-offset: 1px;
69+
border-radius: var(--space-3xs);
70+
}
71+
}
72+
73+
.purl-text {
74+
word-break: break-all;
75+
max-width: 100%;
76+
overflow: hidden;
77+
text-overflow: ellipsis;
78+
white-space: nowrap;
79+
display: block;
80+
}
81+
82+
.purl-tooltip {
83+
word-break: break-all;
84+
85+
> small {
86+
word-break: normal;
87+
}
88+
}
89+
90+
.purl-help-link {
91+
color: unset;
92+
margin-left: var(--space-2xs);
93+
flex-shrink: 0;
94+
95+
&:hover {
96+
color: unset;
97+
}
98+
99+
&:focus {
100+
outline: 2px solid var(--yellow500);
101+
outline-offset: 1px;
102+
border-radius: var(--space-3xs);
103+
}
104+
105+
svg {
106+
margin: 0;
107+
}
108+
}
109+
55110
.links {
56111
> * + * {
57112
margin-top: var(--space-m);

app/models/version.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { alias } from 'macro-decorators';
99
import semverParse from 'semver/functions/parse';
1010

1111
import ajax from '../utils/ajax';
12+
import { addRegistryUrl } from '../utils/purl';
1213

1314
const EIGHT_DAYS = 8 * 24 * 60 * 60 * 1000;
1415

@@ -52,6 +53,15 @@ export default class Version extends Model {
5253
return this.belongsTo('crate').id();
5354
}
5455

56+
/**
57+
* Returns the Package URL (PURL) for this version.
58+
* @type {string}
59+
*/
60+
get purl() {
61+
let basePurl = `pkg:cargo/${this.crateName}@${this.num}`;
62+
return addRegistryUrl(basePurl);
63+
}
64+
5565
get editionMsrv() {
5666
if (this.edition === '2018') {
5767
return '1.31.0';

app/utils/purl.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import window from 'ember-window-mock';
2+
3+
/**
4+
* Adds a repository_url query parameter to a PURL string based on the host.
5+
*
6+
* @param {string} purl - The base PURL string (e.g., "pkg:cargo/[email protected]")
7+
* @param {string} [host] - The host to use for repository URL. Defaults to current window location host.
8+
* @returns {string} The PURL with repository_url parameter added, or unchanged if host is crates.io
9+
*/
10+
export function addRegistryUrl(purl) {
11+
let host = window.location.host;
12+
13+
// Don't add repository_url for the main crates.io registry
14+
if (host === 'crates.io') {
15+
return purl;
16+
}
17+
18+
// Add repository_url query parameter
19+
const repositoryUrl = `https://${host}/`;
20+
const separator = purl.includes('?') ? '&' : '?';
21+
return `${purl}${separator}repository_url=${encodeURIComponent(repositoryUrl)}`;
22+
}

tests/models/version-test.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { module, test } from 'qunit';
22

33
import { calculateReleaseTracks } from '@crates-io/msw/utils/release-tracks';
4+
import window from 'ember-window-mock';
5+
import { setupWindowMock } from 'ember-window-mock/test-support';
46

57
import { setupTest } from 'crates-io/tests/helpers';
68
import setupMsw from 'crates-io/tests/helpers/setup-msw';
79

810
module('Model | Version', function (hooks) {
911
setupTest(hooks);
1012
setupMsw(hooks);
13+
setupWindowMock(hooks);
1114

1215
hooks.beforeEach(function () {
1316
this.store = this.owner.lookup('service:store');
@@ -345,4 +348,36 @@ module('Model | Version', function (hooks) {
345348
assert.ok(version.published_by);
346349
assert.strictEqual(version.published_by.name, 'JD');
347350
});
351+
352+
module('purl', function () {
353+
test('generates PURL for crates.io version', async function (assert) {
354+
let { db, store } = this;
355+
356+
window.location = 'https://crates.io';
357+
358+
let crate = db.crate.create({ name: 'serde' });
359+
db.version.create({ crate, num: '1.0.136' });
360+
361+
let crateRecord = await store.findRecord('crate', crate.name);
362+
let versions = (await crateRecord.versions).slice();
363+
let version = versions[0];
364+
365+
assert.strictEqual(version.purl, 'pkg:cargo/[email protected]');
366+
});
367+
368+
test('generates PURL with registry URL for non-crates.io hosts', async function (assert) {
369+
let { db, store } = this;
370+
371+
window.location = 'https://staging.crates.io';
372+
373+
let crate = db.crate.create({ name: 'test-crate' });
374+
db.version.create({ crate, num: '2.5.0' });
375+
376+
let crateRecord = await store.findRecord('crate', crate.name);
377+
let versions = (await crateRecord.versions).slice();
378+
let version = versions[0];
379+
380+
assert.strictEqual(version.purl, 'pkg:cargo/[email protected]?repository_url=https%3A%2F%2Fstaging.crates.io%2F');
381+
});
382+
});
348383
});

tests/utils/purl-test.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { module, test } from 'qunit';
2+
3+
import window from 'ember-window-mock';
4+
import { setupWindowMock } from 'ember-window-mock/test-support';
5+
6+
import { addRegistryUrl } from 'crates-io/utils/purl';
7+
8+
module('Utils | purl', function (hooks) {
9+
setupWindowMock(hooks);
10+
11+
module('addRegistryUrl()', function () {
12+
test('returns PURL unchanged for crates.io host', function (assert) {
13+
window.location = 'https://crates.io';
14+
15+
let purl = 'pkg:cargo/[email protected]';
16+
let result = addRegistryUrl(purl);
17+
18+
assert.strictEqual(result, purl);
19+
});
20+
21+
test('adds repository_url parameter for non-crates.io hosts', function (assert) {
22+
window.location = 'https://staging.crates.io';
23+
24+
let purl = 'pkg:cargo/[email protected]';
25+
let result = addRegistryUrl(purl);
26+
27+
assert.strictEqual(result, 'pkg:cargo/[email protected]?repository_url=https%3A%2F%2Fstaging.crates.io%2F');
28+
});
29+
30+
test('adds repository_url parameter for custom registry hosts', function (assert) {
31+
window.location = 'https://my-registry.example.com';
32+
33+
let purl = 'pkg:cargo/[email protected]';
34+
let result = addRegistryUrl(purl);
35+
36+
assert.strictEqual(result, 'pkg:cargo/[email protected]?repository_url=https%3A%2F%2Fmy-registry.example.com%2F');
37+
});
38+
39+
test('appends repository_url parameter when PURL already has query parameters', function (assert) {
40+
window.location = 'https://staging.crates.io';
41+
42+
let purl = 'pkg:cargo/[email protected]?arch=x86_64';
43+
let result = addRegistryUrl(purl);
44+
45+
assert.strictEqual(result, 'pkg:cargo/[email protected]?arch=x86_64&repository_url=https%3A%2F%2Fstaging.crates.io%2F');
46+
});
47+
48+
test('properly URL encodes the repository URL', function (assert) {
49+
window.location = 'https://registry.example.com:8080';
50+
51+
let purl = 'pkg:cargo/[email protected]';
52+
let result = addRegistryUrl(purl);
53+
54+
assert.strictEqual(result, 'pkg:cargo/[email protected]?repository_url=https%3A%2F%2Fregistry.example.com%3A8080%2F');
55+
});
56+
57+
test('handles PURL with complex qualifiers', function (assert) {
58+
window.location = 'https://private.registry.co';
59+
60+
let purl = 'pkg:cargo/[email protected]?os=linux&arch=amd64';
61+
let result = addRegistryUrl(purl);
62+
63+
assert.strictEqual(
64+
result,
65+
'pkg:cargo/[email protected]?os=linux&arch=amd64&repository_url=https%3A%2F%2Fprivate.registry.co%2F',
66+
);
67+
});
68+
});
69+
});

0 commit comments

Comments
 (0)