diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 0a630e7..13dd899 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -14,6 +14,7 @@ import Sidebar, { hasSidebarLazyChildren, normalizeSidebarTreeRelativeDropPosition, parseV2CommandSearchQuery, + resolveV2CommandSearchPersistentFilter, type V2CommandSearchItem, resolveSidebarDropNodeFromDomEvent, resolveSidebarTagDropInsertBefore, @@ -27,6 +28,7 @@ import Sidebar, { shouldSkipSidebarLoadOnExpandWhileDragging, shouldSkipSidebarSelectWhileDragging, shouldLoadSidebarNodeOnExpand, + shouldCloseV2CommandSearchOnGlobalKey, shouldRunV2CommandSearchEnter, sortSidebarTableEntries, } from './Sidebar'; @@ -302,6 +304,51 @@ describe('Sidebar locate toolbar', () => { })).toBe(false); }); + it('keeps v2 command search persisted filter after closing the palette', () => { + expect(resolveV2CommandSearchPersistentFilter({ + commandSearchValue: ' org ', + persistedFilter: '', + enabled: true, + isOpen: true, + })).toBe('org'); + + expect(resolveV2CommandSearchPersistentFilter({ + commandSearchValue: '', + persistedFilter: 'org', + enabled: true, + isOpen: false, + })).toBe('org'); + + expect(resolveV2CommandSearchPersistentFilter({ + commandSearchValue: 'org', + persistedFilter: 'org', + enabled: false, + isOpen: true, + })).toBe(''); + }); + + it('closes v2 command search on global escape only while the palette is open', () => { + expect(shouldCloseV2CommandSearchOnGlobalKey({ + key: 'Escape', + isOpen: true, + })).toBe(true); + + expect(shouldCloseV2CommandSearchOnGlobalKey({ + key: 'Esc', + isOpen: true, + })).toBe(true); + + expect(shouldCloseV2CommandSearchOnGlobalKey({ + key: 'Escape', + isOpen: false, + })).toBe(false); + + expect(shouldCloseV2CommandSearchOnGlobalKey({ + key: 'Enter', + isOpen: true, + })).toBe(false); + }); + it('keeps all loaded v2 command table matches once a keyword is entered', () => { const items: V2CommandSearchItem[] = Array.from({ length: 40 }, (_, index) => ({ key: `node-table-${index}`, @@ -538,6 +585,8 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain('handleV2CommandSearchValueChange(event.target.value)'); expect(source).toContain('toggleV2CommandSearchPersistentFilter'); expect(source).toContain('gn-v2-command-filter-switch'); + expect(source).toContain("window.addEventListener('keydown', handleV2CommandSearchGlobalKeyDown, true)"); + expect(source).toContain("window.removeEventListener('keydown', handleV2CommandSearchGlobalKeyDown, true)"); expect(source).toContain('onClick={() => setV2ExplorerFilter(item.key)}'); expect(source).toContain('treeData={isV2Ui ? v2VisibleTreeData : displayTreeData}'); expect(markup).toContain('gn-v2-sidebar-log-footer'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 6d64db6..34146ba 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -648,6 +648,38 @@ export const shouldRunV2CommandSearchEnter = ({ return activeItemCount > 0; }; +export interface V2CommandSearchPersistentFilterState { + commandSearchValue: string; + persistedFilter: string; + enabled: boolean; + isOpen: boolean; +} + +export const resolveV2CommandSearchPersistentFilter = ({ + commandSearchValue, + persistedFilter, + enabled, + isOpen, +}: V2CommandSearchPersistentFilterState): string => { + if (!enabled) return ''; + if (!isOpen) return String(persistedFilter ?? '').trim(); + return String(commandSearchValue ?? '').trim(); +}; + +export interface V2CommandSearchGlobalKeyState { + key: string; + isOpen: boolean; +} + +export const shouldCloseV2CommandSearchOnGlobalKey = ({ + key, + isOpen, +}: V2CommandSearchGlobalKeyState): boolean => { + if (!isOpen) return false; + const normalizedKey = String(key || '').toLowerCase(); + return normalizedKey === 'escape' || normalizedKey === 'esc'; +}; + export const resolveSidebarConnectionIdFromKey = ( key: unknown, connectionIds: string[], @@ -1156,11 +1188,23 @@ const Sidebar: React.FC<{ setV2CommandActiveIndex(0); }, []); + const commitV2CommandSearchPersistentFilter = useCallback((value = v2CommandSearchValue) => { + if (!v2CommandSearchPersistentFilterEnabled) { + return; + } + const nextFilter = value.trim(); + setSearchValue(nextFilter); + if (nextFilter !== v2PersistedSidebarFilter) { + setAppearance({ v2SidebarPersistedFilter: nextFilter }); + } + }, [setAppearance, v2CommandSearchPersistentFilterEnabled, v2CommandSearchValue, v2PersistedSidebarFilter]); + const closeV2CommandSearch = useCallback(() => { + commitV2CommandSearchPersistentFilter(); setIsV2CommandSearchOpen(false); setV2CommandSearchValue(''); setV2CommandActiveIndex(0); - }, []); + }, [commitV2CommandSearchPersistentFilter]); useEffect(() => { setSearchValue(v2PersistedSidebarFilter); @@ -1184,13 +1228,21 @@ const Sidebar: React.FC<{ if (!v2CommandSearchPersistentFilterEnabled) { return; } - const nextFilter = deferredV2CommandSearchValue.trim(); + if (!isV2CommandSearchOpen) { + return; + } + const nextFilter = resolveV2CommandSearchPersistentFilter({ + commandSearchValue: deferredV2CommandSearchValue, + persistedFilter: v2PersistedSidebarFilter, + enabled: v2CommandSearchPersistentFilterEnabled, + isOpen: isV2CommandSearchOpen, + }); setSearchValue(nextFilter); const timer = window.setTimeout(() => { setAppearance({ v2SidebarPersistedFilter: nextFilter }); }, 160); return () => window.clearTimeout(timer); - }, [deferredV2CommandSearchValue, setAppearance, v2CommandSearchPersistentFilterEnabled]); + }, [deferredV2CommandSearchValue, isV2CommandSearchOpen, setAppearance, v2CommandSearchPersistentFilterEnabled, v2PersistedSidebarFilter]); const toggleV2CommandSearchPersistentFilter = useCallback((enabled: boolean) => { const nextFilter = enabled ? v2CommandSearchValue.trim() : ''; @@ -1264,6 +1316,20 @@ const Sidebar: React.FC<{ }, 0); return () => window.clearTimeout(timer); }, [isV2CommandSearchOpen]); + + useEffect(() => { + if (!isV2CommandSearchOpen) return; + const handleV2CommandSearchGlobalKeyDown = (event: KeyboardEvent) => { + if (!shouldCloseV2CommandSearchOnGlobalKey({ key: event.key, isOpen: isV2CommandSearchOpen })) { + return; + } + event.preventDefault(); + event.stopPropagation(); + closeV2CommandSearch(); + }; + window.addEventListener('keydown', handleV2CommandSearchGlobalKeyDown, true); + return () => window.removeEventListener('keydown', handleV2CommandSearchGlobalKeyDown, true); + }, [closeV2CommandSearch, isV2CommandSearchOpen]); // Connection Status State: key -> 'success' | 'error' const [connectionStates, setConnectionStates] = useState>({});