Add initial implementation of /extensions explore command (#19029)

This commit is contained in:
christine betts
2026-02-20 12:30:49 -05:00
committed by GitHub
parent 0f855fc0c4
commit 2bb7aaecd0
10 changed files with 1135 additions and 3 deletions

View File

@@ -0,0 +1,101 @@
/**
* @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,
};
}

View File

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect } from 'react';
import {
useTextBuffer,
type TextBuffer,
} from '../components/shared/text-buffer.js';
import { useUIState } from '../contexts/UIStateContext.js';
import type { GenericListItem } from '../components/shared/SearchableList.js';
const MIN_VIEWPORT_WIDTH = 20;
const VIEWPORT_WIDTH_OFFSET = 8;
export interface UseRegistrySearchResult<T extends GenericListItem> {
filteredItems: T[];
searchBuffer: TextBuffer | undefined;
searchQuery: string;
setSearchQuery: (query: string) => void;
maxLabelWidth: number;
}
export function useRegistrySearch<T extends GenericListItem>(props: {
items: T[];
initialQuery?: string;
onSearch?: (query: string) => void;
}): UseRegistrySearchResult<T> {
const { items, initialQuery = '', onSearch } = props;
const [searchQuery, setSearchQuery] = useState(initialQuery);
useEffect(() => {
onSearch?.(searchQuery);
}, [searchQuery, onSearch]);
const { mainAreaWidth } = useUIState();
const viewportWidth = Math.max(
MIN_VIEWPORT_WIDTH,
mainAreaWidth - VIEWPORT_WIDTH_OFFSET,
);
const searchBuffer = useTextBuffer({
initialText: searchQuery,
initialCursorOffset: searchQuery.length,
viewport: {
width: viewportWidth,
height: 1,
},
singleLine: true,
onChange: (text) => setSearchQuery(text),
});
const maxLabelWidth = 0;
const filteredItems = items;
return {
filteredItems,
searchBuffer,
searchQuery,
setSearchQuery,
maxLabelWidth,
};
}