diff --git a/packages/bruno-app/src/components/GlobalSearchModal/StyledWrapper.js b/packages/bruno-app/src/components/GlobalSearchModal/StyledWrapper.js new file mode 100644 index 000000000..0900e1502 --- /dev/null +++ b/packages/bruno-app/src/components/GlobalSearchModal/StyledWrapper.js @@ -0,0 +1,361 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + /* Screen reader only content */ + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .command-k-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + display: flex; + align-items: flex-start; + justify-content: center; + overflow-y: auto; + z-index: 20; + background-color: transparent; + &:before { + content: ''; + height: 100%; + width: 100%; + left: 0; + opacity: ${(props) => props.theme.modal.backdrop.opacity}; + top: 0; + background: black; + position: fixed; + } + animation: fade-in 0.1s forwards cubic-bezier(0.19, 1, 0.22, 1); + } + .command-k-modal { + background: ${(props) => props.theme.modal.body.bg}; + border: 1px solid ${(props) => props.theme.modal.input.border}; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + width: 90%; + max-width: 600px; + max-height: 70vh; + display: flex; + flex-direction: column; + overflow: hidden; + margin: 80px auto; + animation: fade-and-slide-in-from-top 0.3s forwards cubic-bezier(0.19, 1, 0.22, 1); + will-change: opacity, transform; + } + .command-k-header { + padding: 12px; + border-bottom: 1px solid ${(props) => props.theme.modal.input.border}; + background: ${(props) => props.theme.modal.title.bg}; + } + .search-input-container { + position: relative; + display: flex; + align-items: center; + width: 100%; + padding: 8px 12px; + border: 1px solid ${(props) => props.theme.modal.input.border}; + border-radius: 6px; + background: ${(props) => props.theme.modal.input.bg}; + transition: all 0.2s ease; + &:focus-within { + border-color: ${(props) => props.theme.colors.text.muted}; + box-shadow: 0 0 0 1px ${(props) => props.theme.colors.text.muted}40; + } + .search-icon { + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.8; + margin-right: 8px; + flex-shrink: 0; + } + .clear-button { + background: transparent; + border: none; + padding: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.8; + margin-left: 8px; + border-radius: 4px; + flex-shrink: 0; + &:hover { + background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}; + } + } + } + .search-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: ${(props) => props.theme.text}; + font-size: 13px; + width: 100%; + padding: 0; + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.7; + } + } + .command-k-results { + flex: 1; + overflow-y: auto; + max-height: 400px; + background: ${(props) => props.theme.modal.body.bg}; + scrollbar-width: thin; + padding: 4px; + scroll-behavior: smooth; + /* Webkit scrollbar styling */ + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'}; + border-radius: 4px; + &:hover { + background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}; + } + } + } + .result-item { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 8px; + cursor: pointer; + border-left: 2px solid transparent; + &:hover { + background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}; + } + &.selected { + background: ${(props) => `${props.theme.colors.text.yellow}15`}; + border-left: 2px solid ${(props) => props.theme.colors.text.yellow}; + } + } + .result-icon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.8; + } + .result-content { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + .result-info { + flex: 1; + min-width: 0; + margin-right: 8px; + } + .result-badges { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + .result-name { + font-size: 13px; + margin-bottom: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: ${(props) => props.theme.text}; + letter-spacing: 0.2px; + } + .result-path { + font-size: 12px; + color: ${(props) => props.theme.colors.text.muted}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: 0.1px; + } + .method-badge { + font-size: 11px; + font-weight: 500; + padding: 3px 8px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; + min-width: 55px; + text-align: center; + &.get { + color: #2ecc71; + background: rgba(46, 204, 113, 0.1); + } + &.post { + color: #3498db; + background: rgba(52, 152, 219, 0.1); + } + &.put { + color: #e67e22; + background: rgba(230, 126, 34, 0.1); + } + &.delete { + color: #e74c3c; + background: rgba(231, 76, 60, 0.1); + } + &.patch { + color: #9b59b6; + background: rgba(155, 89, 182, 0.1); + } + &.head { + color: #2980b9; + background: rgba(41, 128, 185, 0.1); + } + &.options { + color: #f1c40f; + background: rgba(241, 196, 15, 0.1); + } + &.unary { + color: #27ae60; + background: rgba(39, 174, 96, 0.12); + font-weight: 600; + } + &.client-streaming { + color: #2980b9; + background: rgba(41, 128, 185, 0.12); + font-weight: 600; + } + &.server-streaming { + color: #f39c12; + background: rgba(243, 156, 18, 0.12); + font-weight: 600; + } + &.bidirectional-streaming, + &.bidi-streaming { + color: #8e44ad; + background: rgba(142, 68, 173, 0.12); + font-weight: 600; + } + } + .result-type { + font-size: 11px; + color: ${(props) => props.theme.colors.text.muted}; + padding: 2px 6px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.3px; + background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)'}; + opacity: 0.8; + flex-shrink: 0; + } + .result-item[data-type="documentation"] { + .result-icon { + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.8; + } + .result-path { + font-size: 12px; + color: ${(props) => props.theme.colors.text.muted}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: 0.1px; + opacity: 0.8; + } + &:hover:not(.selected) { + background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}; + } + } + .no-results, + .empty-state { + padding: 24px 16px; + text-align: center; + color: ${(props) => props.theme.colors.text.muted}; + font-size: 13px; + } + .command-k-footer { + padding: 8px 12px; + border-top: 1px solid ${(props) => props.theme.modal.input.border}; + background: ${(props) => props.theme.colors.surface}; + } + .keyboard-hints { + display: flex; + justify-content: center; + gap: 24px; + color: ${(props) => props.theme.colors.text.muted}; + font-size: 12px; + letter-spacing: 0.2px; + span { + display: flex; + align-items: center; + gap: 6px; + .hint-icon { + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.8; + } + .hint-icon + .hint-icon { + margin-left: -8px; + } + .keycap { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 6px; + border: 1px solid ${(props) => props.theme.modal.input.border}; + border-radius: 4px; + background: ${(props) => + props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}; + font-size: 11px; + font-weight: 500; + font-family: inherit; + line-height: 1; + color: ${(props) => props.theme.text}; + } + } + } + .highlight { + background: ${(props) => `${props.theme.colors.text.yellow}30`}; + border-radius: 2px; + padding: 0 2px; + margin: 0 -1px; + font-weight: 500; + } + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + @keyframes fade-and-slide-in-from-top { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +export default StyledWrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/GlobalSearchModal/constants/index.js b/packages/bruno-app/src/components/GlobalSearchModal/constants/index.js new file mode 100644 index 000000000..f7e174c52 --- /dev/null +++ b/packages/bruno-app/src/components/GlobalSearchModal/constants/index.js @@ -0,0 +1,32 @@ +export const SEARCH_TYPES = { + DOCUMENTATION: 'documentation', + COLLECTION: 'collection', + FOLDER: 'folder', + REQUEST: 'request' +}; + +export const MATCH_TYPES = { + COLLECTION: 'collection', + FOLDER: 'folder', + REQUEST: 'request', + URL: 'url', + PATH: 'path', + DOCUMENTATION: 'documentation' +}; + +export const SEARCH_CONFIG = { + MAX_DEPTH: 20, + FOCUS_DELAY: 100, + SCROLL_BEHAVIOR: 'smooth', + SCROLL_BLOCK: 'nearest', + DEBOUNCE_DELAY: 300 +}; + +export const DOCUMENTATION_RESULT = { + type: SEARCH_TYPES.DOCUMENTATION, + item: { id: 'docs', name: 'Bruno Documentation' }, + name: 'Bruno Documentation', + path: '/', + description: 'Browse the official Bruno documentation', + matchType: MATCH_TYPES.DOCUMENTATION +}; diff --git a/packages/bruno-app/src/components/GlobalSearchModal/index.js b/packages/bruno-app/src/components/GlobalSearchModal/index.js new file mode 100644 index 000000000..5bfb09ff8 --- /dev/null +++ b/packages/bruno-app/src/components/GlobalSearchModal/index.js @@ -0,0 +1,515 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { + IconSearch, + IconX, + IconFolder, + IconBox, + IconFileText, + IconBook +} from '@tabler/icons'; +import { flattenItems, isItemARequest, isItemAFolder, findParentItemInCollection } from 'utils/collections'; +import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs'; +import { hideHomePage } from 'providers/ReduxStore/slices/app'; +import { toggleCollectionItem, toggleCollection } from 'providers/ReduxStore/slices/collections'; +import { mountCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { getDefaultRequestPaneTab } from 'utils/collections'; +import { normalizeQuery, isValidQuery, highlightText, sortResults, getTypeLabel, getItemPath } from './utils/searchUtils'; +import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG, DOCUMENTATION_RESULT } from './constants'; +import StyledWrapper from './StyledWrapper'; + +const GlobalSearchModal = ({ isOpen, onClose }) => { + const [query, setQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const [results, setResults] = useState([]); + const inputRef = useRef(null); + const resultsRef = useRef(null); + const debounceTimeoutRef = useRef(null); + const dispatch = useDispatch(); + + const collections = useSelector((state) => state.collections.collections); + const tabs = useSelector((state) => state.tabs.tabs); + + const createCollectionResults = () => { + const collectionResults = collections.map(collection => ({ + type: SEARCH_TYPES.COLLECTION, + item: collection, + name: collection.name, + path: collection.name, + matchType: MATCH_TYPES.COLLECTION, + collectionUid: collection.uid + })); + + collectionResults.sort((a, b) => a.name.localeCompare(b.name)); + return [DOCUMENTATION_RESULT, ...collectionResults]; + }; + + const searchInCollections = (searchTerms, enablePathMatch) => { + const results = []; + + // Check for documentation match + const queryLower = searchTerms.join(' '); + if (['documentation', 'docs', 'bruno docs'].some(term => term.includes(queryLower))) { + results.push(DOCUMENTATION_RESULT); + } + + collections.forEach(collection => { + // Search collection name + if (searchTerms.every(term => collection.name.toLowerCase().includes(term))) { + results.push({ + type: SEARCH_TYPES.COLLECTION, + item: collection, + name: collection.name, + path: collection.name, + matchType: MATCH_TYPES.COLLECTION, + collectionUid: collection.uid + }); + } + + // Search collection items + const flattenedItems = flattenItems(collection.items); + flattenedItems.forEach(item => { + const itemPath = getItemPath(item, collection, findParentItemInCollection); + const itemPathLower = itemPath.toLowerCase(); + + if (isItemARequest(item)) { + const nameMatch = searchTerms.every(term => item.name.toLowerCase().includes(term)); + const urlMatch = searchTerms.every(term => (item.request?.url || '').toLowerCase().includes(term)); + const pathMatch = enablePathMatch && searchTerms.every(term => itemPathLower.includes(term)); + + if (nameMatch || urlMatch || pathMatch) { + // Check if this is a gRPC request and get the method type + const isGrpcRequest = item.request?.type === 'grpc'; + + let method = item.request?.method || ''; + + if (isGrpcRequest) { + // For gRPC requests, use the methodType + const methodType = item.request?.methodType || 'UNARY'; + method = methodType.toLowerCase().replace(/[_]/g, '-'); + } + + results.push({ + type: SEARCH_TYPES.REQUEST, + item, + name: item.name, + path: itemPath, + matchType: nameMatch ? MATCH_TYPES.REQUEST : urlMatch ? MATCH_TYPES.URL : MATCH_TYPES.PATH, + method, + collectionUid: collection.uid + }); + } + } else if (isItemAFolder(item)) { + const nameMatch = searchTerms.every(term => item.name.toLowerCase().includes(term)); + const pathMatch = enablePathMatch && searchTerms.every(term => itemPathLower.includes(term)); + + if (nameMatch || pathMatch) { + results.push({ + type: SEARCH_TYPES.FOLDER, + item, + name: item.name, + path: itemPath, + matchType: nameMatch ? MATCH_TYPES.FOLDER : MATCH_TYPES.PATH, + collectionUid: collection.uid + }); + } + } + }); + }); + + return results; + }; + + const performSearch = (searchQuery) => { + const normalizedQuery = normalizeQuery(searchQuery); + + if (!normalizedQuery) { + setResults(createCollectionResults()); + return; + } + + if (!isValidQuery(normalizedQuery)) { + setResults([]); + return; + } + + const searchTerms = normalizedQuery.toLowerCase().split(/[\s\/]+/).filter(Boolean); + if (!searchTerms.length) { + setResults([]); + return; + } + + const enablePathMatch = normalizedQuery.includes('/'); + const searchResults = searchInCollections(searchTerms, enablePathMatch); + const sortedResults = sortResults(searchResults); + + setResults(sortedResults); + setSelectedIndex(0); + }; + + const debouncedSearch = useCallback((searchQuery) => { + // Clear existing timeout + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + + // Set new timeout + debounceTimeoutRef.current = setTimeout(() => { + performSearch(searchQuery); + }, SEARCH_CONFIG.DEBOUNCE_DELAY); + }, [collections]); // Depend on collections to recreate when they change + + const expandItemPath = (result) => { + const collection = collections.find(c => c.uid === result.collectionUid); + if (!collection) return; + + ensureCollectionIsMounted(collection); + + if (collection.collapsed) { + dispatch(toggleCollection(collection.uid)); + } + + let currentItem = result.type === SEARCH_TYPES.FOLDER + ? result.item + : findParentItemInCollection(collection, result.item.uid); + + while (currentItem?.type === 'folder') { + if (currentItem.collapsed) { + dispatch(toggleCollectionItem({ collectionUid: collection.uid, itemUid: currentItem.uid })); + } + currentItem = findParentItemInCollection(collection, currentItem.uid); + } + }; + + const ensureCollectionIsMounted = (collection) => { + if (!collection || collection.mountStatus === 'mounted') return; + dispatch(mountCollection({ + collectionUid: collection.uid, + collectionPathname: collection.pathname, + brunoConfig: collection.brunoConfig + })); + }; + + const handleKeyNavigation = (e) => { + const handlers = { + ArrowDown: () => { + e.preventDefault(); + setSelectedIndex(prev => prev < results.length - 1 ? prev + 1 : 0); + }, + ArrowUp: () => { + e.preventDefault(); + setSelectedIndex(prev => prev > 0 ? prev - 1 : results.length - 1); + }, + Enter: () => { + e.preventDefault(); + if (results[selectedIndex]) { + handleResultSelection(results[selectedIndex]); + } + }, + Escape: () => { + e.preventDefault(); + onClose(); + }, + PageDown: () => { + e.preventDefault(); + setSelectedIndex(prev => Math.min(prev + 5, results.length - 1)); + }, + PageUp: () => { + e.preventDefault(); + setSelectedIndex(prev => Math.max(prev - 5, 0)); + }, + Home: () => { + e.preventDefault(); + setSelectedIndex(0); + }, + End: () => { + e.preventDefault(); + setSelectedIndex(results.length - 1); + } + }; + + const handler = handlers[e.key]; + if (handler) handler(); + }; + + const handleResultSelection = (result) => { + const targetCollection = collections.find(c => c.uid === result.collectionUid); + ensureCollectionIsMounted(targetCollection); + + if (result.type === SEARCH_TYPES.DOCUMENTATION) { + window.open('https://docs.usebruno.com/', '_blank'); + onClose(); + return; + } + + expandItemPath(result); + + if (result.type === SEARCH_TYPES.REQUEST) { + dispatch(hideHomePage()); + + const existingTab = tabs.find(tab => tab.uid === result.item.uid); + + if (existingTab) { + dispatch(focusTab({ uid: result.item.uid })); + } else { + dispatch(addTab({ + uid: result.item.uid, + collectionUid: result.collectionUid, + requestPaneTab: getDefaultRequestPaneTab(result.item), + type: 'request', + })); + } + } else if (result.type === SEARCH_TYPES.FOLDER) { + dispatch(addTab({ + uid: result.item.uid, + collectionUid: result.collectionUid, + type: 'folder-settings', + })); + } else if (result.type === SEARCH_TYPES.COLLECTION) { + dispatch(addTab({ + uid: result.item.uid, + collectionUid: result.collectionUid, + type: 'collection-settings', + })); + } + + onClose(); + }; + + const handleQueryChange = (e) => { + const newQuery = e.target.value; + setQuery(newQuery); + + if (newQuery.trim()) { + debouncedSearch(newQuery); + } else { + // For empty queries, search immediately to show collections + performSearch(newQuery); + } + }; + + const clearSearch = () => { + // Clear any pending debounced search + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + + setQuery(''); + setResults([]); + }; + + // Initialize modal when opened + useEffect(() => { + if (isOpen) { + const timeoutId = setTimeout(() => inputRef.current?.focus(), SEARCH_CONFIG.FOCUS_DELAY); + setQuery(''); + performSearch(''); + setSelectedIndex(0); + + return () => clearTimeout(timeoutId); + } else { + // Clear any pending debounced search when modal closes + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + } + }, [isOpen]); + + // Auto-scroll selected item into view + useEffect(() => { + if (resultsRef.current && results.length > 0) { + const selectedElement = resultsRef.current.children[selectedIndex]; + selectedElement?.scrollIntoView({ + behavior: SEARCH_CONFIG.SCROLL_BEHAVIOR, + block: SEARCH_CONFIG.SCROLL_BLOCK + }); + } + }, [selectedIndex, results]); + + // Cleanup debounce timeout on unmount or modal close + useEffect(() => { + return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + }; + }, []); + + const getResultIcon = (type) => { + const iconMap = { + [SEARCH_TYPES.DOCUMENTATION]: IconBook, + [SEARCH_TYPES.COLLECTION]: IconBox, + [SEARCH_TYPES.FOLDER]: IconFolder, + [SEARCH_TYPES.REQUEST]: IconFileText + }; + const IconComponent = iconMap[type] || IconFileText; + return ; + }; + + if (!isOpen) return null; + + return ( + +
+
e.stopPropagation()}> +

Global Search

+

+ Search through collections, requests, folders, and documentation. Use arrow keys to navigate results and Enter to select. +

+
+ {results.length > 0 && query + ? `${results.length} result${results.length === 1 ? '' : 's'} found` + : query && results.length === 0 + ? 'No results found' + : '' + } +
+
+
+
+
+ +
+ {results.length === 0 && query ? ( +
+

+ No results found for "{query}". +
+ + The item might not exist yet, or its collection isn’t mounted. Press Enter here (or open it from the sidebar) to mount the collection automatically. + +

+
+ ) : results.length === 0 ? ( +
+

+ No collections are currently mounted or visible. +
+ + Mount a collection via the sidebar or this search modal, then try again. + +

+
+ ) : ( + results.map((result, index) => { + const isSelected = index === selectedIndex; + const typeLabel = getTypeLabel(result.type); + + return ( +
handleResultSelection(result)} + data-selected={isSelected} + data-type={result.type} + role="option" + aria-selected={isSelected} + aria-label={`${result.name}, ${typeLabel || result.type}${result.method ? `, ${result.method}` : ''}`} + tabIndex={-1} + > +
+ {getResultIcon(result.type)} +
+
+
+
+ {highlightText(result.name, query)} +
+
+ {result.type === SEARCH_TYPES.DOCUMENTATION + ? result.description + : result.type === SEARCH_TYPES.REQUEST + ? highlightText(result.item.request?.url || '', query) + : highlightText(result.path, query)} +
+
+
+ {result.type === SEARCH_TYPES.REQUEST && result.method && ( + + {result.method.toUpperCase().replace(/-/g, ' ')} + + )} + {typeLabel && ( +
+ {typeLabel} +
+ )} +
+
+
+ ); + }) + )} +
+ +
+
+ + + + to navigate + + + + to select + + + + to close + +
+
+
+
+
+ ); +}; + +export default GlobalSearchModal; \ No newline at end of file diff --git a/packages/bruno-app/src/components/GlobalSearchModal/utils/searchUtils.js b/packages/bruno-app/src/components/GlobalSearchModal/utils/searchUtils.js new file mode 100644 index 000000000..b55cdb1ab --- /dev/null +++ b/packages/bruno-app/src/components/GlobalSearchModal/utils/searchUtils.js @@ -0,0 +1,94 @@ +import React from 'react'; +import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG } from '../constants'; + +export const normalizeQuery = (searchQuery) => { + return searchQuery.trim().replace(/\/+/g, '/'); +}; + +export const isValidQuery = (normalizedQuery) => { + return normalizedQuery && + normalizedQuery !== '/' && + !(normalizedQuery.length === 1 && !normalizedQuery.match(/[a-zA-Z0-9]/)); +}; + +export const highlightText = (text, searchQuery) => { + if (!searchQuery) return text; + + try { + const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escapedQuery})`, 'gi'); + return text.split(regex).map((part, i) => + regex.test(part) ? ( + {part} + ) : part + ); + } catch { + return text; + } +}; + +export const sortResults = (results) => { + return results.sort((a, b) => { + // Documentation always first + if (a.type === SEARCH_TYPES.DOCUMENTATION) return -1; + if (b.type === SEARCH_TYPES.DOCUMENTATION) return 1; + + // Sort by match type priority + const matchTypeOrder = { + [MATCH_TYPES.COLLECTION]: 0, + [MATCH_TYPES.FOLDER]: 1, + [MATCH_TYPES.REQUEST]: 2, + [MATCH_TYPES.URL]: 3, + [MATCH_TYPES.PATH]: 4 + }; + const aMatchType = matchTypeOrder[a.matchType] ?? 5; + const bMatchType = matchTypeOrder[b.matchType] ?? 5; + + if (aMatchType !== bMatchType) return aMatchType - bMatchType; + + // Sort by type priority + const typeOrder = { + [SEARCH_TYPES.COLLECTION]: 0, + [SEARCH_TYPES.FOLDER]: 1, + [SEARCH_TYPES.REQUEST]: 2 + }; + const aType = typeOrder[a.type] ?? 3; + const bType = typeOrder[b.type] ?? 3; + + if (aType !== bType) return aType - bType; + + // Finally sort alphabetically + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); +}; + +export const getTypeLabel = (type) => { + const baseLabels = { + [SEARCH_TYPES.DOCUMENTATION]: 'Documentation', + [SEARCH_TYPES.COLLECTION]: 'Collection', + [SEARCH_TYPES.FOLDER]: 'Folder' + }; + + return baseLabels[type] || ''; +}; + +export const getItemPath = (item, collection, findParentItemInCollection) => { + const pathParts = []; + let currentItem = item; + let depth = 0; + const maxDepth = SEARCH_CONFIG.MAX_DEPTH; + + while (currentItem && depth < maxDepth) { + pathParts.unshift(currentItem.name); + const parent = findParentItemInCollection(collection, currentItem.uid); + if (parent) { + currentItem = parent; + depth++; + } else { + break; + } + } + + pathParts.unshift(collection.name); + return pathParts.join('/'); +}; \ No newline at end of file diff --git a/packages/bruno-app/src/components/StatusBar/index.js b/packages/bruno-app/src/components/StatusBar/index.js index a98db747b..25b984ffe 100644 --- a/packages/bruno-app/src/components/StatusBar/index.js +++ b/packages/bruno-app/src/components/StatusBar/index.js @@ -1,9 +1,11 @@ import React, { useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { IconSettings, IconCookie, IconTool } from '@tabler/icons'; -import IconSidebarToggle from 'components/Icons/IconSidebarToggle'; +import { IconSettings, IconCookie, IconTool, IconSearch } from '@tabler/icons'; +import Mousetrap from 'mousetrap'; +import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings'; import ToolHint from 'components/ToolHint'; import Preferences from 'components/Preferences'; +import IconSidebarToggle from 'components/Icons/IconSidebarToggle'; import Cookies from 'components/Cookies'; import Notifications from 'components/Notifications'; import Portal from 'components/Portal'; @@ -26,6 +28,13 @@ const StatusBar = () => { dispatch(openConsole()); }; + const openGlobalSearch = () => { + const bindings = getKeyBindingsForActionAllOS('globalSearch') || []; + bindings.forEach((binding) => { + Mousetrap.trigger(binding); + }); + }; + return ( {preferencesOpen && ( @@ -93,6 +102,19 @@ const StatusBar = () => {
+ +
+ +
+ {t('WELCOME.GLOBAL_SEARCH_TIP_PART1')} {' '}K{' '} + {t('WELCOME.GLOBAL_SEARCH_TIP_PART2')} Ctrl{' '}K{' '} + {t('WELCOME.GLOBAL_SEARCH_TIP_PART3')} +
); diff --git a/packages/bruno-app/src/i18n/translation/en.json b/packages/bruno-app/src/i18n/translation/en.json index 7dda41e42..4f3aff16e 100644 --- a/packages/bruno-app/src/i18n/translation/en.json +++ b/packages/bruno-app/src/i18n/translation/en.json @@ -15,6 +15,9 @@ "IMPORT_COLLECTION": "Import Collection", "COLLECTION_IMPORT_SUCCESS": "Collection imported successfully", "COLLECTION_IMPORT_ERROR": "An error occurred while importing the collection. Check the logs for more information.", - "COLLECTION_OPEN_ERROR": "An error occurred while opening the collection" + "COLLECTION_OPEN_ERROR": "An error occurred while opening the collection", + "GLOBAL_SEARCH_TIP_PART1": "Press", + "GLOBAL_SEARCH_TIP_PART2": "(mac) or", + "GLOBAL_SEARCH_TIP_PART3": "(windows) anytime to quickly search collections, folders, and requests" } } diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js index d8a239907..7aef15122 100644 --- a/packages/bruno-app/src/providers/Hotkeys/index.js +++ b/packages/bruno-app/src/providers/Hotkeys/index.js @@ -6,6 +6,7 @@ import { useSelector, useDispatch } from 'react-redux'; import EnvironmentSettings from 'components/Environments/EnvironmentSettings'; import NetworkError from 'components/ResponsePane/NetworkError'; import NewRequest from 'components/Sidebar/NewRequest'; +import GlobalSearchModal from 'components/GlobalSearchModal'; import { sendRequest, saveRequest, @@ -27,6 +28,7 @@ export const HotkeysProvider = (props) => { const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen); const [showEnvSettingsModal, setShowEnvSettingsModal] = useState(false); const [showNewRequestModal, setShowNewRequestModal] = useState(false); + const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false); const getCurrentCollection = () => { const activeTab = find(tabs, (t) => t.uid === activeTabUid); @@ -149,6 +151,19 @@ export const HotkeysProvider = (props) => { }; }, [activeTabUid, tabs, collections, setShowNewRequestModal]); + // global search (ctrl/cmd + k) + useEffect(() => { + Mousetrap.bind([...getKeyBindingsForActionAllOS('globalSearch')], (e) => { + setShowGlobalSearchModal(true); + + return false; // stop bubbling + }); + + return () => { + Mousetrap.unbind([...getKeyBindingsForActionAllOS('globalSearch')]); + }; + }, []); + // close tab hotkey useEffect(() => { Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => { @@ -247,6 +262,9 @@ export const HotkeysProvider = (props) => { {showNewRequestModal && ( setShowNewRequestModal(false)} /> )} + {showGlobalSearchModal && ( + setShowGlobalSearchModal(false)} /> + )}
{props.children}
); diff --git a/packages/bruno-app/src/providers/Hotkeys/keyMappings.js b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js index 0fd9bf1bc..997eb2cd0 100644 --- a/packages/bruno-app/src/providers/Hotkeys/keyMappings.js +++ b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js @@ -3,6 +3,7 @@ const KeyMapping = { sendRequest: { mac: 'command+enter', windows: 'ctrl+enter', name: 'Send Request' }, editEnvironment: { mac: 'command+e', windows: 'ctrl+e', name: 'Edit Environment' }, newRequest: { mac: 'command+b', windows: 'ctrl+b', name: 'New Request' }, + globalSearch: { mac: 'command+k', windows: 'ctrl+k', name: 'Global Search' }, closeTab: { mac: 'command+w', windows: 'ctrl+w', name: 'Close Tab' }, openPreferences: { mac: 'command+,', windows: 'ctrl+,', name: 'Open Preferences' }, closeBruno: {