mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-23 20:55:41 +00:00
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
515
packages/bruno-app/src/components/GlobalSearchModal/index.js
Normal file
515
packages/bruno-app/src/components/GlobalSearchModal/index.js
Normal file
@@ -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 <IconComponent size={18} stroke={1.5} />;
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div
|
||||
className="command-k-overlay"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="search-modal-title"
|
||||
aria-describedby="search-modal-description"
|
||||
>
|
||||
<div className="command-k-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h1 id="search-modal-title" className="sr-only">Global Search</h1>
|
||||
<p id="search-modal-description" className="sr-only">
|
||||
Search through collections, requests, folders, and documentation. Use arrow keys to navigate results and Enter to select.
|
||||
</p>
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{results.length > 0 && query
|
||||
? `${results.length} result${results.length === 1 ? '' : 's'} found`
|
||||
: query && results.length === 0
|
||||
? 'No results found'
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
<div className="command-k-header">
|
||||
<div className="search-input-container">
|
||||
<IconSearch size={20} className="search-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search collections, requests, or documentation..."
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
onKeyDown={handleKeyNavigation}
|
||||
className="search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
aria-label="Search collections, requests, or documentation"
|
||||
aria-expanded={results.length > 0}
|
||||
aria-controls="search-results"
|
||||
aria-activedescendant={results.length > 0 ? `search-result-${selectedIndex}` : undefined}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="clear-button"
|
||||
aria-label="Clear search query"
|
||||
type="button"
|
||||
>
|
||||
<IconX size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="command-k-results"
|
||||
ref={resultsRef}
|
||||
id="search-results"
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
>
|
||||
{results.length === 0 && query ? (
|
||||
<div className="no-results">
|
||||
<p>
|
||||
No results found for "{query}".
|
||||
<br />
|
||||
<span className="block mt-2">
|
||||
The item might not exist yet, or its collection isn’t mounted. Press <strong>Enter</strong> here (or open it from the sidebar) to mount the collection automatically.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>
|
||||
No collections are currently mounted or visible.
|
||||
<br />
|
||||
<span className="block mt-2">
|
||||
Mount a collection via the sidebar or this search modal, then try again.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
results.map((result, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const typeLabel = getTypeLabel(result.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${result.type}-${result.item.id || result.item.uid}-${index}`}
|
||||
id={`search-result-${index}`}
|
||||
className={`result-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => 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}
|
||||
>
|
||||
<div className="result-icon">
|
||||
{getResultIcon(result.type)}
|
||||
</div>
|
||||
<div className="result-content">
|
||||
<div className="result-info">
|
||||
<div className="result-name">
|
||||
{highlightText(result.name, query)}
|
||||
</div>
|
||||
<div className="result-path">
|
||||
{result.type === SEARCH_TYPES.DOCUMENTATION
|
||||
? result.description
|
||||
: result.type === SEARCH_TYPES.REQUEST
|
||||
? highlightText(result.item.request?.url || '', query)
|
||||
: highlightText(result.path, query)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="result-badges">
|
||||
{result.type === SEARCH_TYPES.REQUEST && result.method && (
|
||||
<span
|
||||
className={`method-badge ${result.method.toLowerCase()}`}
|
||||
aria-label={`HTTP method ${result.method.toUpperCase().replace(/-/g, ' ')}`}
|
||||
>
|
||||
{result.method.toUpperCase().replace(/-/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
{typeLabel && (
|
||||
<div className="result-type" aria-label={`Item type ${typeLabel}`}>
|
||||
{typeLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="command-k-footer">
|
||||
<div className="keyboard-hints" role="region" aria-label="Keyboard shortcuts">
|
||||
<span aria-label="Use up and down arrows to navigate">
|
||||
<span className="keycap" aria-hidden="true">↑</span>
|
||||
<span className="keycap" aria-hidden="true">↓</span>
|
||||
<span className="hint-label">to navigate</span>
|
||||
</span>
|
||||
<span aria-label="Press Enter to select">
|
||||
<span className="keycap" aria-hidden="true">↵</span>
|
||||
<span className="hint-label">to select</span>
|
||||
</span>
|
||||
<span aria-label="Press Escape to close">
|
||||
<span className="keycap" aria-hidden="true">esc</span>
|
||||
<span className="hint-label">to close</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalSearchModal;
|
||||
@@ -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) ? (
|
||||
<span key={i} className="highlight">{part}</span>
|
||||
) : 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('/');
|
||||
};
|
||||
@@ -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 (
|
||||
<StyledWrapper>
|
||||
{preferencesOpen && (
|
||||
@@ -93,6 +102,19 @@ const StatusBar = () => {
|
||||
|
||||
<div className="status-bar-section">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="status-bar-button"
|
||||
data-trigger="search"
|
||||
onClick={openGlobalSearch}
|
||||
tabIndex={0}
|
||||
aria-label="Global Search"
|
||||
>
|
||||
<div className="console-button-content">
|
||||
<IconSearch size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<span className="console-label">Search</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="status-bar-button"
|
||||
data-trigger="cookies"
|
||||
|
||||
@@ -24,6 +24,22 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keycap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1px 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: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -138,6 +138,12 @@ const Welcome = () => {
|
||||
<span className="label ml-2">{t('COMMON.GITHUB')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 select-none">
|
||||
{t('WELCOME.GLOBAL_SEARCH_TIP_PART1')} <span className="keycap">⌘</span>{' '}<span className="keycap">K</span>{' '}
|
||||
{t('WELCOME.GLOBAL_SEARCH_TIP_PART2')} <span className="keycap">Ctrl</span>{' '}<span className="keycap">K</span>{' '}
|
||||
{t('WELCOME.GLOBAL_SEARCH_TIP_PART3')}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<NewRequest collectionUid={currentCollection?.uid} onClose={() => setShowNewRequestModal(false)} />
|
||||
)}
|
||||
{showGlobalSearchModal && (
|
||||
<GlobalSearchModal isOpen={showGlobalSearchModal} onClose={() => setShowGlobalSearchModal(false)} />
|
||||
)}
|
||||
<div>{props.children}</div>
|
||||
</HotkeysContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user