mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-30 11:41:00 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1f2070ea9 | |||
| 8d96c8ce72 | |||
| 878f57c1b7 | |||
| 3be90e0dea | |||
| cb4cbf8d94 | |||
| a02b71df07 | |||
| 0b23546fb8 | |||
| 73eb1247ea | |||
| 9289b879a5 | |||
| a5816a6765 | |||
| 49b70d69e5 |
@@ -224,4 +224,59 @@ describe('ExtensionRegistryClient', () => {
|
|||||||
'Failed to fetch extensions: Not Found',
|
'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;
|
ExtensionRegistryClient.fetchPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all extensions from the registry.
|
||||||
|
*/
|
||||||
|
async getAllExtensions(): Promise<RegistryExtension[]> {
|
||||||
|
return this.fetchAllExtensions();
|
||||||
|
}
|
||||||
|
|
||||||
async getExtensions(
|
async getExtensions(
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 10,
|
limit: number = 10,
|
||||||
@@ -78,11 +85,24 @@ export class ExtensionRegistryClient {
|
|||||||
|
|
||||||
const fzf = new AsyncFzf(allExtensions, {
|
const fzf = new AsyncFzf(allExtensions, {
|
||||||
selector: (ext: RegistryExtension) =>
|
selector: (ext: RegistryExtension) =>
|
||||||
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
|
`${ext.extensionName} ${ext.extensionDescription || ''} ${
|
||||||
|
ext.fullName || ''
|
||||||
|
}`,
|
||||||
fuzzy: 'v2',
|
fuzzy: 'v2',
|
||||||
});
|
});
|
||||||
const results = await fzf.find(query);
|
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> {
|
async getExtension(id: string): Promise<RegistryExtension | undefined> {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
type CommandContext,
|
type CommandContext,
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
|
type SlashCommandActionReturn,
|
||||||
CommandKind,
|
CommandKind,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import open from 'open';
|
import open from 'open';
|
||||||
@@ -35,6 +36,7 @@ import { stat } from 'node:fs/promises';
|
|||||||
import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
|
import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
|
||||||
import { type ConfigLogger } from '../../commands/extensions/utils.js';
|
import { type ConfigLogger } from '../../commands/extensions/utils.js';
|
||||||
import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js';
|
import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js';
|
||||||
|
import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
function showMessageIfNoExtensions(
|
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/';
|
const extensionsUrl = 'https://geminicli.com/extensions/';
|
||||||
|
|
||||||
// Only check for NODE_ENV for explicit test mode, not for unit test framework
|
// Only check for NODE_ENV for explicit test mode, not for unit test framework
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { theme } from '../../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
import { TextInput } from './TextInput.js';
|
import { TextInput } from './TextInput.js';
|
||||||
@@ -31,6 +30,27 @@ export interface SearchableListProps<T extends GenericListItem> {
|
|||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
/** Max items to show at once */
|
/** Max items to show at once */
|
||||||
maxItemsToShow?: number;
|
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 = '',
|
initialSearchQuery = '',
|
||||||
searchPlaceholder = 'Search...',
|
searchPlaceholder = 'Search...',
|
||||||
maxItemsToShow = 10,
|
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({
|
const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({
|
||||||
items,
|
items,
|
||||||
initialQuery: initialSearchQuery,
|
initialQuery: initialSearchQuery,
|
||||||
|
disableFiltering,
|
||||||
|
onSearch,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [scrollOffset, setScrollOffset] = useState(0);
|
const [scrollOffset, setScrollOffset] = useState(0);
|
||||||
|
|
||||||
// Reset selection when filtered items change
|
const prevFilteredKeysRef = React.useRef<string[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveIndex(0);
|
const currentKeys = filteredItems.map((item) => item.key);
|
||||||
setScrollOffset(0);
|
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]);
|
}, [filteredItems]);
|
||||||
|
|
||||||
// Calculate visible items
|
// Calculate visible items
|
||||||
@@ -113,16 +153,25 @@ export function SearchableList<T extends GenericListItem>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
|
||||||
borderColor={theme.border.default}
|
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
padding={1}
|
padding={1}
|
||||||
width="100%"
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Title */}
|
||||||
{title && (
|
{title && (
|
||||||
<Box marginBottom={1}>
|
<Box marginX={1}>
|
||||||
<Text bold>{title}</Text>
|
<Text bold color={theme.text.primary}>
|
||||||
|
{'>'} {title}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{header && (
|
||||||
|
<Box marginX={1} marginTop={1}>
|
||||||
|
{header}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -132,7 +181,9 @@ export function SearchableList<T extends GenericListItem>({
|
|||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
borderColor={theme.border.focused}
|
borderColor={theme.border.focused}
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
marginBottom={1}
|
height={3}
|
||||||
|
marginTop={1}
|
||||||
|
width="100%"
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
buffer={searchBuffer}
|
buffer={searchBuffer}
|
||||||
@@ -143,45 +194,85 @@ export function SearchableList<T extends GenericListItem>({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
{showScrollUp && (
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
<Text color={theme.text.secondary}>▲</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{visibleItems.length === 0 ? (
|
{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) => {
|
visibleItems.map((item, idx) => {
|
||||||
const index = scrollOffset + idx;
|
const index = scrollOffset + idx;
|
||||||
const isActive = index === activeIndex;
|
const isActive = index === activeIndex;
|
||||||
|
|
||||||
|
if (renderItem) {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={item.key}>
|
||||||
|
<Box>{renderItem(item, isActive, maxLabelWidth)}</Box>
|
||||||
|
<Box height={1} />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={item.key} flexDirection="row">
|
<React.Fragment key={item.key}>
|
||||||
<Text
|
<Box flexDirection="row" alignItems="flex-start">
|
||||||
color={isActive ? theme.status.success : theme.text.secondary}
|
<Box minWidth={2} flexShrink={0}>
|
||||||
>
|
<Text
|
||||||
{isActive ? '> ' : ' '}
|
color={
|
||||||
</Text>
|
isActive ? theme.status.success : theme.text.secondary
|
||||||
<Box width={maxLabelWidth + 2}>
|
}
|
||||||
<Text
|
>
|
||||||
color={isActive ? theme.status.success : theme.text.primary}
|
{isActive ? '> ' : ' '}
|
||||||
>
|
</Text>
|
||||||
{item.label}
|
</Box>
|
||||||
</Text>
|
<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>
|
</Box>
|
||||||
{item.description && (
|
<Box height={1} />
|
||||||
<Text color={theme.text.secondary}>{item.description}</Text>
|
</React.Fragment>
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
{showScrollDown && (
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
<Text color={theme.text.secondary}>▼</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Footer/Scroll Indicators */}
|
{/* Footer */}
|
||||||
{(showScrollUp || showScrollDown) && (
|
{footer && (
|
||||||
<Box marginTop={1} justifyContent="center">
|
<Box marginX={1} marginTop={1}>
|
||||||
<Text color={theme.text.secondary}>
|
{typeof footer === 'function'
|
||||||
{showScrollUp ? '▲ ' : ' '}
|
? footer({
|
||||||
{filteredItems.length} items
|
startIndex: scrollOffset,
|
||||||
{showScrollDown ? ' ▼' : ' '}
|
endIndex: Math.min(
|
||||||
</Text>
|
scrollOffset + maxItemsToShow,
|
||||||
|
filteredItems.length,
|
||||||
|
),
|
||||||
|
totalVisible: filteredItems.length,
|
||||||
|
totalItems: items.length,
|
||||||
|
})
|
||||||
|
: footer}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,14 +13,6 @@ import {
|
|||||||
} from '../components/shared/text-buffer.js';
|
} from '../components/shared/text-buffer.js';
|
||||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||||
|
|
||||||
interface FzfResult {
|
|
||||||
item: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
score: number;
|
|
||||||
positions?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GenericListItem {
|
export interface GenericListItem {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -32,6 +24,7 @@ export interface UseFuzzyListProps<T extends GenericListItem> {
|
|||||||
items: T[];
|
items: T[];
|
||||||
initialQuery?: string;
|
initialQuery?: string;
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
|
disableFiltering?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseFuzzyListResult<T extends GenericListItem> {
|
export interface UseFuzzyListResult<T extends GenericListItem> {
|
||||||
@@ -46,6 +39,7 @@ export function useFuzzyList<T extends GenericListItem>({
|
|||||||
items,
|
items,
|
||||||
initialQuery = '',
|
initialQuery = '',
|
||||||
onSearch,
|
onSearch,
|
||||||
|
disableFiltering = false,
|
||||||
}: UseFuzzyListProps<T>): UseFuzzyListResult<T> {
|
}: UseFuzzyListProps<T>): UseFuzzyListResult<T> {
|
||||||
// Search state
|
// Search state
|
||||||
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
||||||
@@ -54,54 +48,81 @@ export function useFuzzyList<T extends GenericListItem>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// FZF instance for fuzzy searching
|
// FZF instance for fuzzy searching
|
||||||
const { fzfInstance, searchMap } = useMemo(() => {
|
// FZF instance for fuzzy searching - skip if filtering is disabled
|
||||||
const map = new Map<string, string>();
|
const fzfInstance = useMemo(() => {
|
||||||
const searchItems: string[] = [];
|
if (disableFiltering) return null;
|
||||||
|
return new AsyncFzf(items, {
|
||||||
items.forEach((item) => {
|
|
||||||
searchItems.push(item.label);
|
|
||||||
map.set(item.label.toLowerCase(), item.key);
|
|
||||||
});
|
|
||||||
|
|
||||||
const fzf = new AsyncFzf(searchItems, {
|
|
||||||
fuzzy: 'v2',
|
fuzzy: 'v2',
|
||||||
casing: 'case-insensitive',
|
casing: 'case-insensitive',
|
||||||
|
selector: (item: T) => item.label,
|
||||||
});
|
});
|
||||||
return { fzfInstance: fzf, searchMap: map };
|
}, [items, disableFiltering]);
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
// Perform search
|
// Perform search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
if (!searchQuery.trim() || !fzfInstance) {
|
if (!searchQuery.trim() || (!fzfInstance && !disableFiltering)) {
|
||||||
setFilteredKeys(items.map((i) => i.key));
|
setFilteredKeys(items.map((i) => i.key));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const doSearch = async () => {
|
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>();
|
if (!active) return;
|
||||||
results.forEach((res: FzfResult) => {
|
|
||||||
const key = searchMap.get(res.item.toLowerCase());
|
const matchedKeys = results.map((res: { item: T }) => res.item.key);
|
||||||
if (key) matchedKeys.add(key);
|
setFilteredKeys((prev) => {
|
||||||
});
|
if (
|
||||||
setFilteredKeys(Array.from(matchedKeys));
|
prev.length === matchedKeys.length &&
|
||||||
onSearch?.(searchQuery);
|
prev.every((key, index) => key === matchedKeys[index])
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return matchedKeys;
|
||||||
|
});
|
||||||
|
onSearch?.(searchQuery);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void doSearch().catch((error) => {
|
void doSearch().catch((error) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error('Search failed:', error);
|
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 () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [searchQuery, fzfInstance, searchMap, items, onSearch]);
|
}, [searchQuery, fzfInstance, items, onSearch, disableFiltering]);
|
||||||
|
|
||||||
// Get mainAreaWidth for search buffer viewport from UIState
|
// Get mainAreaWidth for search buffer viewport from UIState
|
||||||
const { mainAreaWidth } = useUIState();
|
const { mainAreaWidth } = useUIState();
|
||||||
@@ -121,9 +142,10 @@ export function useFuzzyList<T extends GenericListItem>({
|
|||||||
|
|
||||||
// Filtered items to display
|
// Filtered items to display
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
|
if (disableFiltering) return items;
|
||||||
if (!searchQuery) return items;
|
if (!searchQuery) return items;
|
||||||
return items.filter((item) => filteredKeys.includes(item.key));
|
return items.filter((item) => filteredKeys.includes(item.key));
|
||||||
}, [items, filteredKeys, searchQuery]);
|
}, [items, filteredKeys, searchQuery, disableFiltering]);
|
||||||
|
|
||||||
// Calculate max label width for alignment
|
// Calculate max label width for alignment
|
||||||
const maxLabelWidth = useMemo(() => {
|
const maxLabelWidth = useMemo(() => {
|
||||||
@@ -133,10 +155,7 @@ export function useFuzzyList<T extends GenericListItem>({
|
|||||||
const labelFull =
|
const labelFull =
|
||||||
item.label + (item.scopeMessage ? ` ${item.scopeMessage}` : '');
|
item.label + (item.scopeMessage ? ` ${item.scopeMessage}` : '');
|
||||||
const lWidth = getCachedStringWidth(labelFull);
|
const lWidth = getCachedStringWidth(labelFull);
|
||||||
const dWidth = item.description
|
max = Math.max(max, lWidth);
|
||||||
? getCachedStringWidth(item.description)
|
|
||||||
: 0;
|
|
||||||
max = Math.max(max, lWidth, dWidth);
|
|
||||||
});
|
});
|
||||||
return max;
|
return max;
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|||||||
Reference in New Issue
Block a user