Compare commits

...

11 Commits

Author SHA1 Message Date
Christine Betts f1f2070ea9 make changes 2026-02-13 13:58:28 -05:00
Christine Betts 8d96c8ce72 merge 2026-02-13 11:06:19 -05:00
Christine Betts 878f57c1b7 address comments 2026-02-12 15:54:15 -05:00
Christine Betts 3be90e0dea Merge branch 'cb/genericlist' into cb/explore 2026-02-12 15:38:40 -05:00
Christine Betts cb4cbf8d94 address comments 2026-02-12 15:38:02 -05:00
Christine Betts a02b71df07 Add initial /extensions explore implementation 2026-02-12 15:14:06 -05:00
Christine Betts 0b23546fb8 fix arrow keys 2026-02-11 16:13:10 -05:00
Christine Betts 73eb1247ea Merge branch 'main' into cb/genericlist 2026-02-11 14:46:57 -05:00
Christine Betts 9289b879a5 Merge branch 'main' into cb/genericlist 2026-02-11 14:28:41 -05:00
Christine Betts a5816a6765 Add generic searchable list to back settings and extensions 2026-02-11 14:28:05 -05:00
Christine Betts 49b70d69e5 Remove experimental note in extension settings docs 2026-02-11 11:01:35 -05:00
9 changed files with 908 additions and 78 deletions
@@ -224,4 +224,59 @@ describe('ExtensionRegistryClient', () => {
'Failed to fetch extensions: Not Found',
);
});
it('should deduplicate extensions by ID', async () => {
const duplicateExtensions = [
mockExtensions[0],
mockExtensions[0], // Duplicate
mockExtensions[1],
];
fetchMock.mockResolvedValue({
ok: true,
json: async () => duplicateExtensions,
});
const result = await client.getAllExtensions();
expect(result).toHaveLength(2);
expect(result[0].id).toBe('ext1');
expect(result[1].id).toBe('ext2');
});
it('should not return irrelevant results for specific queries', async () => {
const extensions = [
{
...mockExtensions[0],
id: 'conductor',
extensionName: 'conductor',
extensionDescription:
'Conductor is a Gemini CLI extension that allows you to specify, plan, and implement software features.',
fullName: 'google/conductor',
},
{
...mockExtensions[1],
id: 'dataplex',
extensionName: 'dataplex',
extensionDescription:
'Connect to Dataplex Universal Catalog to discover, manage, monitor, and govern data and AI artifacts across your data platform',
fullName: 'google/dataplex',
},
];
fetchMock.mockResolvedValue({
ok: true,
json: async () => extensions,
});
const results = await client.searchExtensions('conductor');
// Conductor should definitely be first
expect(results[0].id).toBe('conductor');
// Dataplex should ideally NOT be in the results, or at least be ranked lower (which it will be if it matches at all).
// But user complaint is that it IS in results.
// Let's assert it is NOT in results if we want strictness.
const ids = results.map((r) => r.id);
expect(ids).not.toContain('dataplex');
});
});
@@ -40,6 +40,13 @@ export class ExtensionRegistryClient {
ExtensionRegistryClient.fetchPromise = null;
}
/**
* Returns all extensions from the registry.
*/
async getAllExtensions(): Promise<RegistryExtension[]> {
return this.fetchAllExtensions();
}
async getExtensions(
page: number = 1,
limit: number = 10,
@@ -78,11 +85,24 @@ export class ExtensionRegistryClient {
const fzf = new AsyncFzf(allExtensions, {
selector: (ext: RegistryExtension) =>
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
`${ext.extensionName} ${ext.extensionDescription || ''} ${
ext.fullName || ''
}`,
fuzzy: 'v2',
});
const results = await fzf.find(query);
return results.map((r: { item: RegistryExtension }) => r.item);
if (results.length === 0) {
return [];
}
const maxScore = results[0].score;
const THRESHOLD_RATIO = 0.75;
const threshold = maxScore * THRESHOLD_RATIO;
return results
.filter((r: { score: number }) => r.score >= threshold)
.map((r: { item: RegistryExtension }) => r.item);
}
async getExtension(id: string): Promise<RegistryExtension | undefined> {
@@ -20,6 +20,7 @@ import {
import {
type CommandContext,
type SlashCommand,
type SlashCommandActionReturn,
CommandKind,
} from './types.js';
import open from 'open';
@@ -35,6 +36,7 @@ import { stat } from 'node:fs/promises';
import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
import { type ConfigLogger } from '../../commands/extensions/utils.js';
import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js';
import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js';
import React from 'react';
function showMessageIfNoExtensions(
@@ -265,7 +267,28 @@ async function restartAction(
}
}
async function exploreAction(context: CommandContext) {
async function exploreAction(
context: CommandContext,
): Promise<SlashCommandActionReturn | void> {
const settings = context.services.settings.merged;
const useRegistryUI = settings.experimental?.extensionRegistry;
if (useRegistryUI) {
const extensionManager = context.services.config?.getExtensionLoader();
if (extensionManager instanceof ExtensionManager) {
return {
type: 'custom_dialog' as const,
component: React.createElement(ExtensionRegistryView, {
onSelect: (extension) => {
debugLogger.debug(`Selected extension: ${extension.extensionName}`);
},
onClose: () => context.ui.removeComponent(),
extensionManager,
}),
};
}
}
const extensionsUrl = 'https://geminicli.com/extensions/';
// Only check for NODE_ENV for explicit test mode, not for unit test framework
@@ -4,8 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { TextInput } from './TextInput.js';
@@ -31,6 +30,27 @@ export interface SearchableListProps<T extends GenericListItem> {
searchPlaceholder?: string;
/** Max items to show at once */
maxItemsToShow?: number;
/** Custom item renderer */
renderItem?: (
item: T,
isActive: boolean,
labelWidth: number,
) => React.JSX.Element;
/** Optional custom header element */
header?: React.ReactNode;
/** Optional custom footer element, can be a function to receive pagination info */
footer?:
| React.ReactNode
| ((pagination: SearchableListPaginationInfo) => React.ReactNode);
/** If true, disables client-side filtering. Useful if items are already filtered externally. */
disableFiltering?: boolean;
}
export interface SearchableListPaginationInfo {
startIndex: number; // 0-indexed
endIndex: number; // 0-indexed, exclusive
totalVisible: number;
totalItems: number;
}
/**
@@ -44,19 +64,39 @@ export function SearchableList<T extends GenericListItem>({
initialSearchQuery = '',
searchPlaceholder = 'Search...',
maxItemsToShow = 10,
}: SearchableListProps<T>): React.JSX.Element {
renderItem,
header,
footer,
disableFiltering = false,
onSearch,
}: SearchableListProps<T> & {
onSearch?: (query: string) => void;
}): React.JSX.Element {
const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({
items,
initialQuery: initialSearchQuery,
disableFiltering,
onSearch,
});
const [activeIndex, setActiveIndex] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
// Reset selection when filtered items change
const prevFilteredKeysRef = React.useRef<string[]>([]);
useEffect(() => {
setActiveIndex(0);
setScrollOffset(0);
const currentKeys = filteredItems.map((item) => item.key);
const prevKeys = prevFilteredKeysRef.current;
const hasChanged =
currentKeys.length !== prevKeys.length ||
currentKeys.some((key, index) => key !== prevKeys[index]);
if (hasChanged) {
setActiveIndex(0);
setScrollOffset(0);
prevFilteredKeysRef.current = currentKeys;
}
}, [filteredItems]);
// Calculate visible items
@@ -113,16 +153,25 @@ export function SearchableList<T extends GenericListItem>({
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
height="100%"
borderStyle="round"
borderColor={theme.border.default}
>
{/* Header */}
{/* Title */}
{title && (
<Box marginBottom={1}>
<Text bold>{title}</Text>
<Box marginX={1}>
<Text bold color={theme.text.primary}>
{'>'} {title}
</Text>
</Box>
)}
{header && (
<Box marginX={1} marginTop={1}>
{header}
</Box>
)}
@@ -132,7 +181,9 @@ export function SearchableList<T extends GenericListItem>({
borderStyle="round"
borderColor={theme.border.focused}
paddingX={1}
marginBottom={1}
height={3}
marginTop={1}
width="100%"
>
<TextInput
buffer={searchBuffer}
@@ -143,45 +194,85 @@ export function SearchableList<T extends GenericListItem>({
)}
{/* List */}
<Box flexDirection="column">
<Box flexDirection="column" flexGrow={1}>
{showScrollUp && (
<Box marginLeft={1}>
<Text color={theme.text.secondary}></Text>
</Box>
)}
{visibleItems.length === 0 ? (
<Text color={theme.text.secondary}>No items found.</Text>
<Box marginLeft={2}>
<Text color={theme.text.secondary}>No items found.</Text>
</Box>
) : (
visibleItems.map((item, idx) => {
const index = scrollOffset + idx;
const isActive = index === activeIndex;
if (renderItem) {
return (
<React.Fragment key={item.key}>
<Box>{renderItem(item, isActive, maxLabelWidth)}</Box>
<Box height={1} />
</React.Fragment>
);
}
return (
<Box key={item.key} flexDirection="row">
<Text
color={isActive ? theme.status.success : theme.text.secondary}
>
{isActive ? '> ' : ' '}
</Text>
<Box width={maxLabelWidth + 2}>
<Text
color={isActive ? theme.status.success : theme.text.primary}
>
{item.label}
</Text>
<React.Fragment key={item.key}>
<Box flexDirection="row" alignItems="flex-start">
<Box minWidth={2} flexShrink={0}>
<Text
color={
isActive ? theme.status.success : theme.text.secondary
}
>
{isActive ? '> ' : ' '}
</Text>
</Box>
<Box width={maxLabelWidth + 2}>
<Text
bold={isActive}
color={
isActive ? theme.status.success : theme.text.primary
}
>
{item.label}
</Text>
</Box>
{item.description && (
<Text color={theme.text.secondary} wrap="truncate-end">
{' '}
| {item.description}
</Text>
)}
</Box>
{item.description && (
<Text color={theme.text.secondary}>{item.description}</Text>
)}
</Box>
<Box height={1} />
</React.Fragment>
);
})
)}
{showScrollDown && (
<Box marginLeft={1}>
<Text color={theme.text.secondary}></Text>
</Box>
)}
</Box>
{/* Footer/Scroll Indicators */}
{(showScrollUp || showScrollDown) && (
<Box marginTop={1} justifyContent="center">
<Text color={theme.text.secondary}>
{showScrollUp ? '▲ ' : ' '}
{filteredItems.length} items
{showScrollDown ? ' ▼' : ' '}
</Text>
{/* Footer */}
{footer && (
<Box marginX={1} marginTop={1}>
{typeof footer === 'function'
? footer({
startIndex: scrollOffset,
endIndex: Math.min(
scrollOffset + maxItemsToShow,
filteredItems.length,
),
totalVisible: filteredItems.length,
totalItems: items.length,
})
: footer}
</Box>
)}
</Box>
@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders as render } from '../../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { ExtensionRegistryView } from './ExtensionRegistryView.js';
import {
ExtensionRegistryClient,
type RegistryExtension,
} from '../../../config/extensionRegistryClient.js';
import { type ExtensionManager } from '../../../config/extension-manager.js';
vi.mock('../../../config/extensionRegistryClient.js');
const mockExtensions = [
{
id: 'ext-1',
extensionName: 'Extension 1',
extensionDescription: 'Description 1',
repoDescription: 'Repo Description 1',
},
{
id: 'ext-2',
extensionName: 'Extension 2',
extensionDescription: 'Description 2',
repoDescription: 'Repo Description 2',
},
];
describe('ExtensionRegistryView', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render loading state initially', async () => {
// Return a promise that doesn't resolve immediately to keep the loading state active
vi.spyOn(
ExtensionRegistryClient.prototype,
'searchExtensions',
).mockReturnValue(new Promise(() => {}));
const mockExtensionManager = {
getExtensions: vi.fn().mockReturnValue([]),
};
const { lastFrame } = render(
<ExtensionRegistryView
extensionManager={mockExtensionManager as unknown as ExtensionManager}
/>,
);
expect(lastFrame()).toContain('Loading extensions...');
});
it('should render extensions after fetching', async () => {
vi.spyOn(
ExtensionRegistryClient.prototype,
'searchExtensions',
).mockResolvedValue(mockExtensions as unknown as RegistryExtension[]);
const mockExtensionManager = {
getExtensions: vi.fn().mockReturnValue([]),
};
const { lastFrame } = render(
<ExtensionRegistryView
extensionManager={mockExtensionManager as unknown as ExtensionManager}
/>,
);
// Wait for effect and debounce
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Add a small delay for debounce/async logic if needed, though mocking resolved value should be enough if called immediately
});
const frame = lastFrame();
expect(frame).toContain('Extension 1');
expect(frame).toContain('Description 1');
expect(frame).toContain('Extension 2');
expect(frame).toContain('Description 2');
});
it('should render error message on fetch failure', async () => {
vi.spyOn(
ExtensionRegistryClient.prototype,
'searchExtensions',
).mockRejectedValue(new Error('Fetch failed'));
const mockExtensionManager = {
getExtensions: vi.fn().mockReturnValue([]),
};
const { lastFrame } = render(
<ExtensionRegistryView
extensionManager={mockExtensionManager as unknown as ExtensionManager}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
const frame = lastFrame();
expect(frame).toContain('Error loading extensions:');
expect(frame).toContain('Fetch failed');
});
it('should call onSelect when an item is selected', async () => {
vi.spyOn(
ExtensionRegistryClient.prototype,
'searchExtensions',
).mockResolvedValue(mockExtensions as unknown as RegistryExtension[]);
const onSelect = vi.fn();
const mockExtensionManager = {
getExtensions: vi.fn().mockReturnValue([]),
};
const { stdin } = render(
<ExtensionRegistryView
onSelect={onSelect}
extensionManager={mockExtensionManager as unknown as ExtensionManager}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
// Press Enter to select the first item
await act(async () => {
stdin.write('\r');
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(onSelect).toHaveBeenCalledWith(mockExtensions[0]);
});
});
@@ -0,0 +1,184 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
import { SearchableList } from '../shared/SearchableList.js';
import type { GenericListItem } from '../../hooks/useFuzzyList.js';
import { theme } from '../../semantic-colors.js';
import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js';
import { ExtensionUpdateState } from '../../state/extensions.js';
import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import type { ExtensionManager } from '../../../config/extension-manager.js';
interface ExtensionRegistryViewProps {
onSelect?: (extension: RegistryExtension) => void;
onClose?: () => void;
extensionManager: ExtensionManager;
}
interface ExtensionItem extends GenericListItem {
extension: RegistryExtension;
}
export function ExtensionRegistryView({
onSelect,
onClose,
extensionManager,
}: ExtensionRegistryViewProps): React.JSX.Element {
const { extensions, loading, error, search } = useExtensionRegistry();
const config = useConfig();
const { extensionsUpdateState } = useExtensionUpdates(
extensionManager,
() => 0,
config.getEnableExtensionReloading(),
);
const installedExtensions = extensionManager.getExtensions();
const items: ExtensionItem[] = useMemo(
() =>
extensions.map((ext) => ({
key: ext.id,
label: ext.extensionName,
description: ext.extensionDescription || ext.repoDescription,
extension: ext,
})),
[extensions],
);
const handleSelect = (item: ExtensionItem) => {
onSelect?.(item.extension);
};
const renderItem = (
item: ExtensionItem,
isActive: boolean,
_labelWidth: number,
) => {
const isInstalled = installedExtensions.some(
(e) => e.name === item.extension.extensionName,
);
const updateState = extensionsUpdateState.get(item.extension.extensionName);
const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE;
return (
<Box flexDirection="row" width="100%" justifyContent="space-between">
<Box flexDirection="row" flexShrink={1} minWidth={0}>
<Box width={2} flexShrink={0}>
<Text
color={isActive ? theme.status.success : theme.text.secondary}
>
{isActive ? '> ' : ' '}
</Text>
</Box>
<Box flexShrink={0}>
<Text
bold={isActive}
color={isActive ? theme.status.success : theme.text.primary}
>
{item.label}
</Text>
</Box>
<Box flexShrink={0} marginX={1}>
<Text color={theme.text.secondary}>|</Text>
</Box>
{isInstalled && (
<Box marginRight={1} flexShrink={0}>
<Text color={theme.status.success}>[Installed]</Text>
</Box>
)}
{hasUpdate && (
<Box marginRight={1} flexShrink={0}>
<Text color={theme.status.warning}>[Update available]</Text>
</Box>
)}
<Box flexShrink={1} minWidth={0}>
<Text color={theme.text.secondary} wrap="truncate-end">
{item.description}
</Text>
</Box>
</Box>
<Box flexShrink={0} marginLeft={2} width={8} flexDirection="row">
<Text color={theme.status.warning}></Text>
<Text color={isActive ? theme.status.success : theme.text.secondary}>
{' '}
{item.extension.stars || 0}
</Text>
</Box>
</Box>
);
};
const header = (
<Box flexDirection="row" justifyContent="space-between" width="100%">
<Box flexShrink={1}>
<Text color={theme.text.secondary} wrap="truncate">
Browse and search extensions from the registry.
</Text>
</Box>
<Box flexShrink={0} marginLeft={2}>
<Text color={theme.text.secondary}>
{installedExtensions.length &&
`${installedExtensions.length} installed`}
</Text>
</Box>
</Box>
);
const footer = ({
startIndex,
endIndex,
totalVisible,
}: {
startIndex: number;
endIndex: number;
totalVisible: number;
}) => (
<Text color={theme.text.secondary}>
({startIndex + 1}-{endIndex}) / {totalVisible}
</Text>
);
if (loading) {
return (
<Box padding={1}>
<Text color={theme.text.secondary}>Loading extensions...</Text>
</Box>
);
}
if (error) {
return (
<Box padding={1} flexDirection="column">
<Text color={theme.status.error}>Error loading extensions:</Text>
<Text color={theme.text.secondary}>{error}</Text>
</Box>
);
}
return (
<SearchableList<ExtensionItem>
title="Extensions"
items={items}
onSelect={handleSelect}
onClose={onClose || (() => {})}
searchPlaceholder="Search extension gallery"
renderItem={renderItem}
header={header}
footer={footer}
maxItemsToShow={8}
disableFiltering={true}
onSearch={search}
/>
);
}
@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { useExtensionRegistry } from './useExtensionRegistry.js';
import {
ExtensionRegistryClient,
type RegistryExtension,
} from '../../config/extensionRegistryClient.js';
import { act } from 'react';
vi.mock('../../config/extensionRegistryClient.js');
const mockExtensions = [
{
id: 'ext-1',
extensionName: 'Extension 1',
extensionDescription: 'Description 1',
},
{
id: 'ext-2',
extensionName: 'Extension 2',
extensionDescription: 'Description 2',
},
] as RegistryExtension[];
describe('useExtensionRegistry', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should fetch extensions on mount', async () => {
vi.spyOn(
ExtensionRegistryClient.prototype,
'searchExtensions',
).mockResolvedValue(mockExtensions);
const { result } = renderHook(() => useExtensionRegistry());
expect(result.current.loading).toBe(true);
expect(result.current.extensions).toEqual([]);
await act(async () => {
await Promise.resolve();
});
expect(result.current.loading).toBe(false);
expect(result.current.extensions).toEqual(mockExtensions);
expect(
ExtensionRegistryClient.prototype.searchExtensions,
).toHaveBeenCalledWith('');
});
it('should handle search with debounce', async () => {
vi.spyOn(
ExtensionRegistryClient.prototype,
'searchExtensions',
).mockResolvedValue(mockExtensions);
const { result } = renderHook(() => useExtensionRegistry());
await act(async () => {
await Promise.resolve();
});
// Initial load done
expect(
ExtensionRegistryClient.prototype.searchExtensions,
).toHaveBeenCalledTimes(1);
// Search
act(() => {
result.current.search('test');
});
// Should not happen immediately due to debounce
expect(
ExtensionRegistryClient.prototype.searchExtensions,
).toHaveBeenCalledTimes(1);
// Advance time
await act(async () => {
vi.advanceTimersByTime(300);
await Promise.resolve(); // Allow potential async effects to run
});
expect(
ExtensionRegistryClient.prototype.searchExtensions,
).toHaveBeenCalledTimes(2);
expect(
ExtensionRegistryClient.prototype.searchExtensions,
).toHaveBeenCalledWith('test');
});
it('should handle race conditions by ignoring outdated responses', async () => {
// Setup a delayed response for the first query 'a'
let resolveA: (value: RegistryExtension[]) => void;
const promiseA = new Promise<RegistryExtension[]>((resolve) => {
resolveA = resolve;
});
// Immediate response for query 'b'
const responseB = [mockExtensions[1]];
vi.spyOn(
ExtensionRegistryClient.prototype,
'searchExtensions',
).mockImplementation(async (query) => {
if (query === 'a') return promiseA;
if (query === 'b') return responseB;
return [];
});
const { result } = renderHook(() => useExtensionRegistry(''));
await act(async () => {
await Promise.resolve(); // Initial load empty
});
// Search 'a'
act(() => {
result.current.search('a');
vi.advanceTimersByTime(300);
});
// Search 'b' immediately after (conceptually, though heavily simplified test here)
// Actually, to test race condition:
// 1. Trigger search 'a'.
// 2. Trigger search 'b'.
// 3. 'b' resolves.
// 4. 'a' resolves later.
// 5. State should match 'b'.
act(() => {
result.current.search('b');
vi.advanceTimersByTime(300);
});
await act(async () => {
await Promise.resolve(); // 'b' resolves immediately
});
expect(result.current.extensions).toEqual(responseB);
// Now resolve 'a'
await act(async () => {
resolveA!(mockExtensions);
await Promise.resolve();
});
// Should still be 'b' because 'a' was outdated
expect(result.current.extensions).toEqual(responseB);
});
it('should not update state if extensions are identical', async () => {
vi.spyOn(
ExtensionRegistryClient.prototype,
'searchExtensions',
).mockResolvedValue(mockExtensions);
const { result } = renderHook(() => useExtensionRegistry());
await act(async () => {
await Promise.resolve();
});
const initialExtensions = result.current.extensions;
// Trigger another search that returns identical content
act(() => {
result.current.search('test');
vi.advanceTimersByTime(300);
});
await act(async () => {
await Promise.resolve();
});
// The reference should be exactly the same
expect(result.current.extensions).toBe(initialExtensions);
});
});
@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
ExtensionRegistryClient,
type RegistryExtension,
} from '../../config/extensionRegistryClient.js';
export interface UseExtensionRegistryResult {
extensions: RegistryExtension[];
loading: boolean;
error: string | null;
search: (query: string) => void;
}
export function useExtensionRegistry(
initialQuery = '',
): UseExtensionRegistryResult {
const [extensions, setExtensions] = useState<RegistryExtension[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const client = useMemo(() => new ExtensionRegistryClient(), []);
// Ref to track the latest query to avoid race conditions
const latestQueryRef = useRef(initialQuery);
// Ref for debounce timeout
const debounceTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const searchExtensions = useCallback(
async (query: string) => {
try {
setLoading(true);
const results = await client.searchExtensions(query);
// Only update if this is still the latest query
if (query === latestQueryRef.current) {
// Check if results are different from current extensions
setExtensions((prev) => {
if (
prev.length === results.length &&
prev.every((ext, i) => ext.id === results[i].id)
) {
return prev;
}
return results;
});
setError(null);
setLoading(false);
}
} catch (err) {
if (query === latestQueryRef.current) {
setError(err instanceof Error ? err.message : String(err));
setExtensions([]);
setLoading(false);
}
}
},
[client],
);
const search = useCallback(
(query: string) => {
latestQueryRef.current = query;
// Clear existing timeout
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Debounce
debounceTimeoutRef.current = setTimeout(() => {
void searchExtensions(query);
}, 300);
},
[searchExtensions],
);
// Initial load
useEffect(() => {
void searchExtensions(initialQuery);
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [initialQuery, searchExtensions]);
return {
extensions,
loading,
error,
search,
};
}
+56 -37
View File
@@ -13,14 +13,6 @@ import {
} from '../components/shared/text-buffer.js';
import { getCachedStringWidth } from '../utils/textUtils.js';
interface FzfResult {
item: string;
start: number;
end: number;
score: number;
positions?: number[];
}
export interface GenericListItem {
key: string;
label: string;
@@ -32,6 +24,7 @@ export interface UseFuzzyListProps<T extends GenericListItem> {
items: T[];
initialQuery?: string;
onSearch?: (query: string) => void;
disableFiltering?: boolean;
}
export interface UseFuzzyListResult<T extends GenericListItem> {
@@ -46,6 +39,7 @@ export function useFuzzyList<T extends GenericListItem>({
items,
initialQuery = '',
onSearch,
disableFiltering = false,
}: UseFuzzyListProps<T>): UseFuzzyListResult<T> {
// Search state
const [searchQuery, setSearchQuery] = useState(initialQuery);
@@ -54,54 +48,81 @@ export function useFuzzyList<T extends GenericListItem>({
);
// FZF instance for fuzzy searching
const { fzfInstance, searchMap } = useMemo(() => {
const map = new Map<string, string>();
const searchItems: string[] = [];
items.forEach((item) => {
searchItems.push(item.label);
map.set(item.label.toLowerCase(), item.key);
});
const fzf = new AsyncFzf(searchItems, {
// FZF instance for fuzzy searching - skip if filtering is disabled
const fzfInstance = useMemo(() => {
if (disableFiltering) return null;
return new AsyncFzf(items, {
fuzzy: 'v2',
casing: 'case-insensitive',
selector: (item: T) => item.label,
});
return { fzfInstance: fzf, searchMap: map };
}, [items]);
}, [items, disableFiltering]);
// Perform search
useEffect(() => {
let active = true;
if (!searchQuery.trim() || !fzfInstance) {
if (!searchQuery.trim() || (!fzfInstance && !disableFiltering)) {
setFilteredKeys(items.map((i) => i.key));
return;
}
const doSearch = async () => {
const results = await fzfInstance.find(searchQuery);
// If filtering is disabled, or no query/fzf, just return all items (or handle external search elsewhere)
if (disableFiltering) {
onSearch?.(searchQuery);
// When filtering is disabled, we assume the items passed in are already filtered
// so we set filteredKeys to all items
const allKeys = items.map((i) => i.key);
setFilteredKeys((prev) => {
if (
prev.length === allKeys.length &&
prev.every((key, index) => key === allKeys[index])
) {
return prev;
}
return allKeys;
});
return;
}
if (!active) return;
if (fzfInstance) {
const results = await fzfInstance.find(searchQuery);
const matchedKeys = new Set<string>();
results.forEach((res: FzfResult) => {
const key = searchMap.get(res.item.toLowerCase());
if (key) matchedKeys.add(key);
});
setFilteredKeys(Array.from(matchedKeys));
onSearch?.(searchQuery);
if (!active) return;
const matchedKeys = results.map((res: { item: T }) => res.item.key);
setFilteredKeys((prev) => {
if (
prev.length === matchedKeys.length &&
prev.every((key, index) => key === matchedKeys[index])
) {
return prev;
}
return matchedKeys;
});
onSearch?.(searchQuery);
}
};
void doSearch().catch((error) => {
// eslint-disable-next-line no-console
console.error('Search failed:', error);
setFilteredKeys(items.map((i) => i.key)); // Reset to all items on error
const allKeys = items.map((i) => i.key);
setFilteredKeys((prev) => {
if (
prev.length === allKeys.length &&
prev.every((key, index) => key === allKeys[index])
) {
return prev;
}
return allKeys;
});
});
return () => {
active = false;
};
}, [searchQuery, fzfInstance, searchMap, items, onSearch]);
}, [searchQuery, fzfInstance, items, onSearch, disableFiltering]);
// Get mainAreaWidth for search buffer viewport from UIState
const { mainAreaWidth } = useUIState();
@@ -121,9 +142,10 @@ export function useFuzzyList<T extends GenericListItem>({
// Filtered items to display
const filteredItems = useMemo(() => {
if (disableFiltering) return items;
if (!searchQuery) return items;
return items.filter((item) => filteredKeys.includes(item.key));
}, [items, filteredKeys, searchQuery]);
}, [items, filteredKeys, searchQuery, disableFiltering]);
// Calculate max label width for alignment
const maxLabelWidth = useMemo(() => {
@@ -133,10 +155,7 @@ export function useFuzzyList<T extends GenericListItem>({
const labelFull =
item.label + (item.scopeMessage ? ` ${item.scopeMessage}` : '');
const lWidth = getCachedStringWidth(labelFull);
const dWidth = item.description
? getCachedStringWidth(item.description)
: 0;
max = Math.max(max, lWidth, dWidth);
max = Math.max(max, lWidth);
});
return max;
}, [items]);