Skip to content

Commit 1853eba

Browse files
add sourceUrl to support packages hosted external to the package manager repo
1 parent 876742a commit 1853eba

File tree

7 files changed

+138
-19
lines changed

7 files changed

+138
-19
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
name: Test Source URL
2+
description: A test package with source URL
3+
type: package
4+
version: 1.0.0
5+
sourceUrl: https://example.com/test-package
6+
tags:
7+
- test
8+
- source-url

src/services/package-manager/MetadataScanner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export class MetadataScanner {
249249
items: [], // Initialize empty items array for all components
250250
author: metadata.author,
251251
authorUrl: metadata.authorUrl,
252+
sourceUrl: metadata.sourceUrl,
252253
}
253254
}
254255

src/services/package-manager/__tests__/MetadataScanner.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ describe("MetadataScanner", () => {
4444
})
4545

4646
describe("Basic Metadata Scanning", () => {
47-
it("should discover components with English metadata", async () => {
47+
it("should discover components with English metadata and sourceUrl", async () => {
4848
// Mock directory structure
4949
const mockDirents = [
5050
{
@@ -86,6 +86,7 @@ name: Test Component
8686
description: A test component
8787
type: mcp server
8888
version: 1.0.0
89+
sourceUrl: https://example.com/component1
8990
`),
9091
)
9192

@@ -96,6 +97,54 @@ version: 1.0.0
9697
expect(items[0].type).toBe("mcp server")
9798
expect(items[0].url).toBe("https://example.com/repo/tree/main/component1")
9899
expect(items[0].path).toBe("component1")
100+
expect(items[0].sourceUrl).toBe("https://example.com/component1")
101+
})
102+
it("should handle missing sourceUrl in metadata", async () => {
103+
const mockDirents = [
104+
{
105+
name: "component2",
106+
isDirectory: () => true,
107+
isFile: () => false,
108+
},
109+
{
110+
name: "metadata.en.yml",
111+
isDirectory: () => false,
112+
isFile: () => true,
113+
},
114+
] as Dirent[]
115+
116+
const mockEmptyDirents = [] as Dirent[]
117+
const mockStats = {
118+
isDirectory: () => true,
119+
isFile: () => true,
120+
mtime: new Date(),
121+
} as Stats
122+
123+
const mockedFs = jest.mocked(fs)
124+
mockedFs.stat.mockResolvedValue(mockStats)
125+
;(mockedFs.readdir as any).mockImplementation(async (path: any, options?: any) => {
126+
if (path.toString().includes("/component2/")) {
127+
return options?.withFileTypes ? mockEmptyDirents : []
128+
}
129+
return options?.withFileTypes ? mockDirents : mockDirents.map((d) => d.name)
130+
})
131+
mockedFs.readFile.mockResolvedValue(
132+
Buffer.from(`
133+
name: Test Component 2
134+
description: A test component without sourceUrl
135+
type: mcp server
136+
version: 1.0.0
137+
`),
138+
)
139+
140+
const items = await metadataScanner.scanDirectory(mockBasePath, mockRepoUrl)
141+
142+
expect(items).toHaveLength(1)
143+
expect(items[0].name).toBe("Test Component 2")
144+
expect(items[0].type).toBe("mcp server")
145+
expect(items[0].url).toBe("https://example.com/repo/tree/main/component2")
146+
expect(items[0].path).toBe("component2")
147+
expect(items[0].sourceUrl).toBeUndefined()
99148
})
100149
})
101150
})

src/services/package-manager/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const baseMetadataSchema = z.object({
1111
tags: z.array(z.string()).optional(),
1212
author: z.string().optional(),
1313
authorUrl: z.string().url("Author URL must be a valid URL").optional(),
14+
sourceUrl: z.string().url("Source URL must be a valid URL").optional(),
1415
})
1516

1617
/**

src/services/package-manager/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface BaseMetadata {
2727
tags?: string[]
2828
author?: string
2929
authorUrl?: string
30+
sourceUrl?: string
3031
}
3132

3233
/**

webview-ui/src/components/package-manager/components/PackageManagerItemCard.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,19 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
6464
}
6565

6666
const handleOpenUrl = () => {
67-
let urlToOpen = item.sourceUrl && isValidUrl(item.sourceUrl) ? item.sourceUrl : item.repoUrl
67+
// If sourceUrl is present and valid, use it directly without modifications
68+
if (item.sourceUrl && isValidUrl(item.sourceUrl)) {
69+
return vscode.postMessage({
70+
type: "openExternal",
71+
url: item.sourceUrl,
72+
})
73+
}
6874

69-
// If we have a defaultBranch, append it to the URL
75+
// Otherwise use repoUrl with git path information
76+
let urlToOpen = item.repoUrl
7077
if (item.defaultBranch) {
7178
urlToOpen = `${urlToOpen}/tree/${item.defaultBranch}`
72-
// If we also have a path, append it
7379
if (item.path) {
74-
// Ensure path uses forward slashes and doesn't start with one
7580
const normalizedPath = item.path.replace(/\\/g, "/").replace(/^\/+/, "")
7681
urlToOpen = `${urlToOpen}/${normalizedPath}`
7782
}
@@ -192,11 +197,17 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
192197
)}
193198
</div>
194199

195-
<Button onClick={handleOpenUrl}>
196-
<span className="codicon codicon-link-external mr-2"></span>
197-
{item.sourceUrl
198-
? t("package_manager:item_card.view")
199-
: item.sourceName || t("package_manager:item_card.source")}
200+
<Button
201+
onClick={handleOpenUrl}
202+
aria-label={
203+
item.sourceUrl && isValidUrl(item.sourceUrl)
204+
? ""
205+
: item.sourceName || t("package_manager:item_card.source")
206+
}>
207+
<span
208+
className={`codicon codicon-link-external${!item.sourceUrl || !isValidUrl(item.sourceUrl) ? " mr-2" : ""}`}></span>
209+
{(!item.sourceUrl || !isValidUrl(item.sourceUrl)) &&
210+
(item.sourceName || t("package_manager:item_card.source"))}
200211
</Button>
201212
</div>
202213

webview-ui/src/components/package-manager/components/__tests__/PackageManagerItemCard.test.tsx

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -95,18 +95,66 @@ describe("PackageManagerItemCard", () => {
9595
expect(screen.getByText(/Apr \d{1,2}, 2025/)).toBeInTheDocument()
9696
})
9797

98-
it("should handle source URL click", () => {
99-
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
98+
describe("URL handling", () => {
99+
it("should use sourceUrl directly when present and valid", () => {
100+
const itemWithSourceUrl = {
101+
...mockItem,
102+
sourceUrl: "https://example.com/direct-link",
103+
defaultBranch: "main",
104+
path: "some/path",
105+
}
106+
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithSourceUrl} />)
107+
108+
const button = screen.getByRole("button", { name: /^$/ }) // Button with no text, only icon
109+
fireEvent.click(button)
110+
111+
expect(mockPostMessage).toHaveBeenCalledWith({
112+
type: "openExternal",
113+
url: "https://example.com/direct-link",
114+
})
115+
})
116+
117+
it("should use repoUrl with git path when sourceUrl is not present", () => {
118+
const itemWithGitPath = {
119+
...mockItem,
120+
defaultBranch: "main",
121+
path: "some/path",
122+
}
123+
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithGitPath} />)
124+
125+
const button = screen.getByRole("button", { name: /Source/i })
126+
fireEvent.click(button)
127+
128+
expect(mockPostMessage).toHaveBeenCalledWith({
129+
type: "openExternal",
130+
url: "test-url/tree/main/some/path",
131+
})
132+
})
100133

101-
// Find the source button by its text content
102-
const sourceButton = screen.getByRole("button", {
103-
name: /Source/i,
134+
it("should show only icon when sourceUrl is present and valid", () => {
135+
const itemWithSourceUrl = {
136+
...mockItem,
137+
sourceUrl: "https://example.com/direct-link",
138+
}
139+
renderWithProviders(<PackageManagerItemCard {...defaultProps} item={itemWithSourceUrl} />)
140+
141+
// Find the source button by its empty aria-label
142+
const button = screen.getByRole("button", {
143+
name: "", // Empty aria-label when sourceUrl is present
144+
})
145+
expect(button.querySelector(".codicon-link-external")).toBeInTheDocument()
146+
expect(button.textContent).toBe("") // Verify no text content
104147
})
105-
fireEvent.click(sourceButton)
106148

107-
expect(mockPostMessage).toHaveBeenCalledWith({
108-
type: "openExternal",
109-
url: "test-url",
149+
it("should show text label when sourceUrl is not present", () => {
150+
renderWithProviders(<PackageManagerItemCard {...defaultProps} />)
151+
152+
// Find the source button by its aria-label
153+
const button = screen.getByRole("button", {
154+
name: "Source",
155+
})
156+
expect(button.querySelector(".codicon-link-external")).toBeInTheDocument()
157+
expect(button).toHaveTextContent(/Source/i)
110158
})
111159
})
112160

0 commit comments

Comments
 (0)