mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-24 21:10:43 -07:00
Add support for linking in the extension registry (#23153)
This commit is contained in:
@@ -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}`,
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user