Add support for linking in the extension registry (#23153)

This commit is contained in:
kevinjwang1
2026-03-20 08:08:34 -07:00
committed by GitHub
parent 5a3c7154df
commit 7a65c1e91d
5 changed files with 131 additions and 13 deletions

View File

@@ -710,10 +710,14 @@ describe('extensionsCommand', () => {
size: 100,
} as Stats);
await linkAction!(mockContext, packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'link',
});
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: packageName,
type: 'link',
},
undefined,
undefined,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.INFO,
text: `Linking extension from "${packageName}"...`,
@@ -733,10 +737,14 @@ describe('extensionsCommand', () => {
} as Stats);
await linkAction!(mockContext, packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'link',
});
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: packageName,
type: 'link',
},
undefined,
undefined,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.ERROR,
text: `Failed to link extension from "${packageName}": ${errorMessage}`,

View File

@@ -286,6 +286,11 @@ async function exploreAction(
await installAction(context, extension.url, requestConsentOverride);
context.ui.removeComponent();
},
onLink: async (extension, requestConsentOverride) => {
debugLogger.log(`Linking extension: ${extension.extensionName}`);
await linkAction(context, extension.url, requestConsentOverride);
context.ui.removeComponent();
},
onClose: () => context.ui.removeComponent(),
extensionManager,
}),
@@ -533,7 +538,11 @@ async function installAction(
}
}
async function linkAction(context: CommandContext, args: string) {
async function linkAction(
context: CommandContext,
args: string,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) {
const extensionLoader =
context.services.agentContext?.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
@@ -582,8 +591,11 @@ async function linkAction(context: CommandContext, args: string) {
source: sourceFilepath,
type: 'link',
};
const extension =
await extensionLoader.installOrUpdateExtension(installMetadata);
const extension = await extensionLoader.installOrUpdateExtension(
installMetadata,
undefined,
requestConsentOverride,
);
context.ui.addItem({
type: MessageType.INFO,
text: `Extension "${extension.name}" linked successfully.`,

View File

@@ -32,13 +32,20 @@ const mockExtension: RegistryExtension = {
licenseKey: 'Apache-2.0',
};
const linkableExtension: RegistryExtension = {
...mockExtension,
url: '/local/path/to/extension',
};
describe('ExtensionDetails', () => {
let mockOnBack: ReturnType<typeof vi.fn>;
let mockOnInstall: ReturnType<typeof vi.fn>;
let mockOnLink: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockOnBack = vi.fn();
mockOnInstall = vi.fn();
mockOnLink = vi.fn();
});
const renderDetails = async (isInstalled = false) =>
@@ -47,6 +54,7 @@ describe('ExtensionDetails', () => {
extension={mockExtension}
onBack={mockOnBack}
onInstall={mockOnInstall}
onLink={mockOnLink}
isInstalled={isInstalled}
/>,
);
@@ -117,4 +125,47 @@ describe('ExtensionDetails', () => {
expect(mockOnInstall).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('should call onLink when "l" is pressed and is linkable', async () => {
const { stdin, waitUntilReady } = await renderWithProviders(
<ExtensionDetails
extension={linkableExtension}
onBack={mockOnBack}
onInstall={mockOnInstall}
onLink={mockOnLink}
isInstalled={false}
/>,
);
await waitUntilReady();
await React.act(async () => {
stdin.write('l');
});
await waitFor(() => {
expect(mockOnLink).toHaveBeenCalled();
});
});
it('should NOT show "Link" button for GitHub extensions', async () => {
const { lastFrame, waitUntilReady } = await renderDetails(false);
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).not.toContain('[L] Link');
});
});
it('should show "Link" button for local extensions', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ExtensionDetails
extension={linkableExtension}
onBack={mockOnBack}
onInstall={mockOnInstall}
onLink={mockOnLink}
isInstalled={false}
/>,
);
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain('[L] Link');
});
});
});

View File

@@ -19,6 +19,9 @@ export interface ExtensionDetailsProps {
onInstall: (
requestConsentOverride: (consent: string) => Promise<boolean>,
) => void | Promise<void>;
onLink: (
requestConsentOverride: (consent: string) => Promise<boolean>,
) => void | Promise<void>;
isInstalled: boolean;
}
@@ -26,6 +29,7 @@ export function ExtensionDetails({
extension,
onBack,
onInstall,
onLink,
isInstalled,
}: ExtensionDetailsProps): React.JSX.Element {
const keyMatchers = useKeyMatchers();
@@ -35,6 +39,11 @@ export function ExtensionDetails({
} | null>(null);
const [isInstalling, setIsInstalling] = useState(false);
const isLinkable =
!extension.url.startsWith('http') &&
!extension.url.startsWith('git@') &&
!extension.url.startsWith('sso://');
useKeypress(
(key) => {
if (consentRequest) {
@@ -56,6 +65,7 @@ export function ExtensionDetails({
onBack();
return true;
}
if (keyMatchers[Command.RETURN](key) && !isInstalled && !isInstalling) {
setIsInstalling(true);
void onInstall(
@@ -66,6 +76,16 @@ export function ExtensionDetails({
);
return true;
}
if (key.name === 'l' && isLinkable && !isInstalled && !isInstalling) {
setIsInstalling(true);
void onLink(
(prompt: string) =>
new Promise((resolve) => {
setConsentRequest({ prompt, resolve });
}),
);
return true;
}
return false;
},
{ isActive: true, priority: true },
@@ -230,8 +250,11 @@ export function ExtensionDetails({
understand the permissions it requires and the actions it may
perform.
</Text>
<Box marginTop={1}>
<Text color={theme.text.primary}>[{'Enter'}] Install</Text>
<Box marginTop={1} flexDirection="row">
<Box marginRight={2}>
<Text color={theme.text.primary}>[{'Enter'}] Install</Text>
</Box>
{isLinkable && <Text color={theme.text.primary}>[L] Link</Text>}
</Box>
</Box>
)}

View File

@@ -29,6 +29,10 @@ export interface ExtensionRegistryViewProps {
extension: RegistryExtension,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) => void | Promise<void>;
onLink?: (
extension: RegistryExtension,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) => void | Promise<void>;
onClose?: () => void;
extensionManager: ExtensionManager;
}
@@ -39,6 +43,7 @@ interface ExtensionItem extends GenericListItem {
export function ExtensionRegistryView({
onSelect,
onLink,
onClose,
extensionManager,
}: ExtensionRegistryViewProps): React.JSX.Element {
@@ -96,6 +101,22 @@ export function ExtensionRegistryView({
[onSelect, extensionManager],
);
const handleLink = useCallback(
async (
extension: RegistryExtension,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) => {
await onLink?.(extension, requestConsentOverride);
// Refresh installed extensions list
setInstalledExtensions(extensionManager.getExtensions());
// Go back to the search page (list view)
setSelectedExtension(null);
},
[onLink, extensionManager],
);
const renderItem = useCallback(
(item: ExtensionItem, isActive: boolean, _labelWidth: number) => {
const isInstalled = installedExtensions.some(
@@ -260,6 +281,9 @@ export function ExtensionRegistryView({
onInstall={async (requestConsentOverride) => {
await handleInstall(selectedExtension, requestConsentOverride);
}}
onLink={async (requestConsentOverride) => {
await handleLink(selectedExtension, requestConsentOverride);
}}
isInstalled={installedExtensions.some(
(e) => e.name === selectedExtension.extensionName,
)}