import { Input, Flex, List, Typography, Modal, Spin, message, Form, Button, Card, Divider } from 'antd' import { createContext, useEffect, useState, useRef, createElement, useContext, useCallback } from 'react' import axios from 'axios' import { LoadingOutlined } from '@ant-design/icons' import PropTypes from 'prop-types' import { useNavigate } from 'react-router-dom' import StateDisplay from '../common/StateDisplay' import IdDisplay from '../common/IdDisplay' import config from '../../../config' import { getModelByName, getModelByPrefix } from '../../../database/ObjectModels' import InfoCircleIcon from '../../Icons/InfoCircleIcon' import { ApiServerContext } from './ApiServerContext' import { AuthContext } from './AuthContext' import { ElectronContext } from './ElectronContext' const SpotlightContext = createContext() const SpotlightContent = ({ isActive, openKey, defaultQuery, onRequestClose, isElectron }) => { const { Text } = Typography const navigate = useNavigate() const [loading, setLoading] = useState(false) const [query, setQuery] = useState('') const [listData, setListData] = useState([]) const [messageApi, contextHolder] = message.useMessage() const [inputPrefix, setInputPrefix] = useState(null) const { fetchSpotlightData } = useContext(ApiServerContext) const { token } = useContext(AuthContext) const { openInternalUrl } = useContext(ElectronContext) // Refs for throttling/debouncing const lastFetchTime = useRef(0) const pendingQuery = useRef(null) const fetchTimeoutRef = useRef(null) const inputRef = useRef(null) const formRef = useRef(null) // Helper function to parse prefix and mode from query const parsePrefix = (query) => { // Check for prefix format: XXX: or XXX? or XXX^ if (query.length >= 4) { const potentialPrefix = query.substring(0, 3) const modeChar = query[3] // Check if it's a valid mode character if ([':', '?', '^'].includes(modeChar)) { const prefixModel = getModelByPrefix(potentialPrefix) if (prefixModel && prefixModel.prefix === potentialPrefix) { return { ...prefixModel, mode: modeChar } } } } return null } const fetchData = async (searchQuery) => { if (!searchQuery || !searchQuery.trim()) return try { // Update last fetch time lastFetchTime.current = Date.now() // Clear any pending queries pendingQuery.current = null setLoading(true) setListData([]) let data // Check if we have a prefix with ? mode (filter mode) if (inputPrefix && inputPrefix.mode === '?') { // For filter mode, parse the searchQuery which is in format like "VEN?name=Tom" const queryParts = searchQuery.split('?') const prefix = queryParts[0] const queryParams = queryParts[1] || '' // Parse the query parameters const params = new URLSearchParams(queryParams) // For ? mode, use axios directly with query params const response = await axios.get( `${config.backendUrl}/spotlight/${prefix}`, { params: params, headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } } ) data = response.data } else { // For other modes (:, ^), use fetchSpotlightData from ApiServerContext data = await fetchSpotlightData(searchQuery.trim()) } setLoading(false) // If the query contains a prefix mode character, and the response is an object, wrap it in an array if ( /[:?^]/.test(searchQuery) && data && !Array.isArray(data) && typeof data === 'object' ) { setListData([data]) } else { setListData(data) } // Check if there's a pending query after this fetch completes if (pendingQuery.current !== null) { const timeToNextFetch = Math.max( 0, 1000 - (Date.now() - lastFetchTime.current) ) scheduleNextFetch(timeToNextFetch) } } catch (error) { setLoading(false) messageApi.error('An error occurred while fetching data.') console.error('Spotlight fetch error:', error) } } const checkAndFetchData = (searchQuery) => { // Store the latest query pendingQuery.current = searchQuery // Calculate time since last fetch const now = Date.now() const timeSinceLastFetch = now - lastFetchTime.current // If we've waited at least 1 second since last fetch, fetch immediately if (timeSinceLastFetch >= 1000) { if (fetchTimeoutRef.current) { clearTimeout(fetchTimeoutRef.current) fetchTimeoutRef.current = null } fetchData(searchQuery) } else { // Otherwise, schedule fetch for when 1 second has passed if (!fetchTimeoutRef.current) { const timeToWait = 1000 - timeSinceLastFetch scheduleNextFetch(timeToWait) } // We don't need to do anything if a fetch is already scheduled // as the latest query is already stored in pendingQuery } } const scheduleNextFetch = (delay) => { if (fetchTimeoutRef.current) { clearTimeout(fetchTimeoutRef.current) } fetchTimeoutRef.current = setTimeout(() => { fetchTimeoutRef.current = null if (pendingQuery.current !== null) { fetchData(pendingQuery.current) } }, delay) } const handleSpotlightChange = (formData) => { const newQuery = formData.query || '' setQuery(newQuery) // Build the full search query with prefix if available let fullQuery = newQuery if (inputPrefix) { fullQuery = inputPrefix.prefix + inputPrefix.mode + newQuery } // Check if we need to fetch data checkAndFetchData(fullQuery) } // Focus the input element const focusInput = () => { setTimeout(() => { if (inputRef.current) { const input = inputRef.current.input if (input) { input.focus() } } }, 50) } // Custom handler for input changes to handle prefix logic const handleInputChange = (e) => { const value = e.target.value // If the input is empty or being cleared if (!value || value.trim() === '') { // Only clear the prefix if the input is completely empty if (value === '') { setInputPrefix(null) } if (formRef.current) { formRef.current.setFieldsValue({ query: value }) } return } // Check if the input contains a prefix (format: XXX:, XXX?, or XXX^) const upperValue = value.toUpperCase() const prefixInfo = parsePrefix(upperValue) // If it's a valid prefix if (prefixInfo) { setInputPrefix(prefixInfo) // Remove the prefix from the input value, keeping only what comes after the mode character const remainingValue = value.substring(4) if (formRef.current) { formRef.current.setFieldsValue({ query: remainingValue }) } setQuery(remainingValue) return } // Update the form value normally if (formRef.current) { formRef.current.setFieldsValue({ query: value }) } } // Helper to get row actions and default action for a model const getRowActions = (model) => (model?.actions || []).filter((action) => action.row === true) const getDefaultRowAction = (model) => getRowActions(model).find((action) => action.default) const triggerRowAction = (action, item) => { if (action && action.url) { // Try to get the id (support _id or id) const itemId = item._id || item.id const url = action.url(itemId) if (url && url !== '#') { if (isElectron) { openInternalUrl(url) } else { navigate(url) } if (onRequestClose) onRequestClose() } } } // Handle key down events for backspace behavior and navigation const handleKeyDown = (e) => { if (listData.length > 0) { // Enter key - trigger default action for first item if (e.key === 'Enter') { e.preventDefault() const item = listData[0] let type = item.type || item.objectType || inputPrefix?.type const model = getModelByName(type) const defaultAction = model ? getDefaultRowAction(model) : null if (defaultAction) { triggerRowAction(defaultAction, item) } return } // Number keys 1-9 - trigger default action for corresponding item if (/^[1-9]$/.test(e.key)) { e.preventDefault() const index = parseInt(e.key, 10) if (index < listData.length) { const item = listData[index] let type = item.type || item.objectType || inputPrefix?.type const model = getModelByName(type) const defaultAction = model ? getDefaultRowAction(model) : null if (defaultAction) { triggerRowAction(defaultAction, item) } } return } } // If backspace is pressed and there's a prefix but the input is empty if (e.key === 'Backspace' && inputPrefix && query === '') { setInputPrefix(null) e.preventDefault() return } } // Focus and select text in input when modal becomes visible useEffect(() => { if (isActive && inputRef.current) { // Use a small timeout to ensure the modal is fully rendered and visible setTimeout(() => { const input = inputRef.current.input if (input) { input.focus() input.select() // Select all text } }, 50) } }, [isActive]) // Focus input when inputPrefix changes useEffect(() => { if (isActive) { focusInput() } }, [inputPrefix, isActive]) // Update form value when query changes useEffect(() => { if (isActive && formRef.current) { formRef.current.setFieldsValue({ query: query }) } }, [query, isActive]) // Apply/refresh defaultQuery when the component is opened (modal) or mounted (standalone) useEffect(() => { if (!isActive) return const nextDefaultQuery = defaultQuery || '' // Set prefix based on default query if provided if (nextDefaultQuery) { const upperQuery = nextDefaultQuery.toUpperCase() const prefixInfo = parsePrefix(upperQuery) if (prefixInfo) { setInputPrefix(prefixInfo) const remainingValue = nextDefaultQuery.substring( prefixInfo.prefix.length + 1 ) setQuery(remainingValue) checkAndFetchData(nextDefaultQuery) } else { setInputPrefix(null) setQuery(nextDefaultQuery) checkAndFetchData(nextDefaultQuery) } } else { setQuery('') setInputPrefix(null) // Keep previous listData behavior (don’t force-clear on open) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [openKey, isActive]) // Cleanup on unmount useEffect(() => { return () => { if (fetchTimeoutRef.current) { clearTimeout(fetchTimeoutRef.current) } } }, []) // Helper function to get mode description const getModeDescription = (mode) => { switch (mode) { case ':': return 'ID/Reference lookup' case '?': return 'Filter' case '^': return 'Search' default: return '' } } const spotlightContent = (
{inputPrefix.prefix} {inputPrefix.mode} ) : undefined } suffix={ {inputPrefix?.mode && ( {getModeDescription(inputPrefix.mode)} )} } spinning={loading} size='small' /> } onChange={handleInputChange} onKeyDown={handleKeyDown} />
{listData.length > 0 && ( <> {isElectron && }
{ let type = item.objectType || inputPrefix?.type const model = getModelByName(type) const Icon = model?.icon const rowActions = getRowActions(model) let shortcutText = '' if (index === 0) { shortcutText = 'ENTER' } else if (index <= 9) { shortcutText = index.toString() } return ( {Icon ? : null} {item.name ? ( {item.name} ) : null} {item?.state ? ( ) : null} {rowActions .filter((action) => !action?.default) .map((action, i) => (
)}
) return ( <> {contextHolder} {spotlightContent} ) } SpotlightContent.propTypes = { isActive: PropTypes.bool, openKey: PropTypes.number, defaultQuery: PropTypes.string, onRequestClose: PropTypes.func, isElectron: PropTypes.bool } SpotlightContent.defaultProps = { isActive: true, openKey: 0, defaultQuery: '', onRequestClose: null } const SpotlightProvider = ({ children }) => { const [showModal, setShowModal] = useState(false) const [openKey, setOpenKey] = useState(0) const [defaultQuery, setDefaultQuery] = useState('') const showSpotlight = (nextDefaultQuery = '') => { setDefaultQuery(nextDefaultQuery) setOpenKey((k) => k + 1) setShowModal(true) } return ( setShowModal(false)} closeIcon={null} footer={null} width={700} styles={{ content: { padding: 0, borderRadius: '12px' } }} destroyOnHidden={true} > setShowModal(false)} /> {children} ) } SpotlightProvider.propTypes = { children: PropTypes.node.isRequired } const ElectronSpotlightContentPage = () => { const cardRef = useRef(null) const resizeTimeoutRef = useRef(null) const { resizeSpotlightWindow, isElectron } = useContext(ElectronContext) // Function to measure and resize window const updateWindowHeight = useCallback(() => { if (!cardRef.current || !isElectron || !resizeSpotlightWindow) return // Clear any pending resize if (resizeTimeoutRef.current) { clearTimeout(resizeTimeoutRef.current) } // Debounce the resize to avoid too many calls resizeTimeoutRef.current = setTimeout(() => { try { const cardElement = cardRef.current if (!cardElement) return // Get the scroll height of the card const scrollHeight = cardElement.scrollHeight // Add some padding for better appearance (e.g., 10px top and bottom) const padding = 0 const newHeight = scrollHeight + padding * 2 // Set minimum height to prevent window from being too small const minHeight = 30 const finalHeight = Math.max(newHeight, minHeight) // Call IPC to resize the window resizeSpotlightWindow(finalHeight) } catch (error) { console.warn('Failed to update spotlight window height:', error) } }, 100) // 100ms debounce }, [isElectron, resizeSpotlightWindow]) // Use ResizeObserver to watch for content changes useEffect(() => { if (!cardRef.current || !isElectron || !resizeSpotlightWindow) return const cardElement = cardRef.current // Create ResizeObserver to watch for size changes const resizeObserver = new ResizeObserver(() => { updateWindowHeight() }) resizeObserver.observe(cardElement) // Initial measurement after a short delay to ensure content is rendered const initialTimeout = setTimeout(() => { updateWindowHeight() }, 200) return () => { resizeObserver.disconnect() if (resizeTimeoutRef.current) { clearTimeout(resizeTimeoutRef.current) } clearTimeout(initialTimeout) } }, [isElectron, resizeSpotlightWindow, updateWindowHeight]) return (
) } export { SpotlightProvider, SpotlightContext, SpotlightContent, ElectronSpotlightContentPage }