feat: add Ctrl+F search bar to canvas
- Ctrl+F / Cmd+F opens floating search bar at top-center of canvas - Filters nodes by label, IP, hostname and service name (case-insensitive) - Shows match count and no-results message - Click result selects node and flies camera to it with animation - Escape or × closes the bar - groupRect nodes excluded from results - Handles grouped nodes with correct absolute position for navigation
This commit is contained in:
@@ -18,6 +18,7 @@ import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { nodeTypes } from './nodes/nodeTypes'
|
||||
import { edgeTypes } from './edges/edgeTypes'
|
||||
import { SearchBar } from './SearchBar'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
|
||||
interface CanvasContainerProps {
|
||||
@@ -88,6 +89,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
size={1}
|
||||
color={theme.colors.canvasDotColor}
|
||||
/>
|
||||
<SearchBar />
|
||||
<Controls>
|
||||
<ControlButton
|
||||
onClick={() => setLassoMode((m) => !m)}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useReactFlow } from '@xyflow/react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { NODE_TYPE_LABELS } from '@/types'
|
||||
|
||||
export function SearchBar() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { nodes, setSelectedNode } = useCanvasStore()
|
||||
const { setCenter } = useReactFlow()
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus()
|
||||
}, [open])
|
||||
|
||||
const q = query.toLowerCase().trim()
|
||||
const results = q
|
||||
? nodes.filter((n) => {
|
||||
if (n.data.type === 'groupRect') return false
|
||||
return (
|
||||
n.data.label?.toLowerCase().includes(q) ||
|
||||
n.data.ip?.toLowerCase().includes(q) ||
|
||||
n.data.hostname?.toLowerCase().includes(q) ||
|
||||
(n.data.services ?? []).some((s) => s.service_name?.toLowerCase().includes(q))
|
||||
)
|
||||
})
|
||||
: []
|
||||
|
||||
const goToNode = (id: string) => {
|
||||
const node = nodes.find((n) => n.id === id)
|
||||
if (!node) return
|
||||
setSelectedNode(id)
|
||||
// For grouped nodes, add parent's absolute position
|
||||
let absX = node.position.x
|
||||
let absY = node.position.y
|
||||
if (node.parentId) {
|
||||
const parent = nodes.find((n) => n.id === node.parentId)
|
||||
if (parent) { absX += parent.position.x; absY += parent.position.y }
|
||||
}
|
||||
const w = node.measured?.width ?? node.width ?? 200
|
||||
const h = node.measured?.height ?? node.height ?? 80
|
||||
setCenter(absX + w / 2, absY + h / 2, { zoom: 1.5, duration: 500 })
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="nodrag nowheel"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
width: 360,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px' }}>
|
||||
<Search size={14} style={{ color: '#8b949e', flexShrink: 0 }} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by name, IP, hostname or service…"
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#e6edf3',
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
{query && (
|
||||
<span style={{ fontSize: 11, color: '#6e7681', flexShrink: 0 }}>
|
||||
{results.length} result{results.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setOpen(false); setQuery('') }}
|
||||
aria-label="Close search"
|
||||
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 2 }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
|
||||
{results.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => goToNode(n.id)}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '7px 12px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#21262d')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||
>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{n.data.label}
|
||||
</span>
|
||||
{n.data.ip && (
|
||||
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
||||
{n.data.ip}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 10, color: '#6e7681', flexShrink: 0 }}>
|
||||
{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q && results.length === 0 && (
|
||||
<div style={{ borderTop: '1px solid #30363d', padding: '10px 12px', fontSize: 12, color: '#6e7681', textAlign: 'center' }}>
|
||||
No results for “{query}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { SearchBar } from '../SearchBar'
|
||||
import * as canvasStore from '@/stores/canvasStore'
|
||||
|
||||
vi.mock('@/stores/canvasStore')
|
||||
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
useReactFlow: () => ({ setCenter: vi.fn() }),
|
||||
}))
|
||||
|
||||
function makeNode(id: string, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
type: 'server',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label: id, type: 'server', status: 'online', services: [], ip: null, hostname: null },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function setupStore(nodes: unknown[] = []) {
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes,
|
||||
setSelectedNode: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
|
||||
}
|
||||
|
||||
describe('SearchBar', () => {
|
||||
beforeEach(() => {
|
||||
setupStore([])
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('is hidden by default', () => {
|
||||
render(<SearchBar />)
|
||||
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('opens on Ctrl+F', () => {
|
||||
render(<SearchBar />)
|
||||
openSearch()
|
||||
expect(screen.getByPlaceholderText(/search/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('opens on Cmd+F', () => {
|
||||
render(<SearchBar />)
|
||||
fireEvent.keyDown(window, { key: 'f', metaKey: true })
|
||||
expect(screen.getByPlaceholderText(/search/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('closes on Escape', () => {
|
||||
render(<SearchBar />)
|
||||
openSearch()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('closes when X button is clicked', () => {
|
||||
render(<SearchBar />)
|
||||
openSearch()
|
||||
fireEvent.click(screen.getByLabelText('Close search'))
|
||||
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('filters by label', () => {
|
||||
setupStore([
|
||||
makeNode('n1', { data: { label: 'My Router', type: 'router', status: 'online', services: [], ip: null, hostname: null } }),
|
||||
makeNode('n2', { data: { label: 'My NAS', type: 'nas', status: 'online', services: [], ip: null, hostname: null } }),
|
||||
])
|
||||
render(<SearchBar />)
|
||||
openSearch()
|
||||
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'router' } })
|
||||
expect(screen.getByText('My Router')).toBeDefined()
|
||||
expect(screen.queryByText('My NAS')).toBeNull()
|
||||
})
|
||||
|
||||
it('filters by IP', () => {
|
||||
setupStore([
|
||||
makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: '192.168.1.10', hostname: null } }),
|
||||
makeNode('n2', { data: { label: 'Server B', type: 'server', status: 'online', services: [], ip: '10.0.0.1', hostname: null } }),
|
||||
])
|
||||
render(<SearchBar />)
|
||||
openSearch()
|
||||
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: '192.168' } })
|
||||
expect(screen.getByText('Server A')).toBeDefined()
|
||||
expect(screen.queryByText('Server B')).toBeNull()
|
||||
})
|
||||
|
||||
it('filters by service name', () => {
|
||||
setupStore([
|
||||
makeNode('n1', { data: { label: 'Web Server', type: 'server', status: 'online', services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }], ip: null, hostname: null } }),
|
||||
makeNode('n2', { data: { label: 'DB Server', type: 'server', status: 'online', services: [{ service_name: 'mysql', port: 3306, protocol: 'tcp' }], ip: null, hostname: null } }),
|
||||
])
|
||||
render(<SearchBar />)
|
||||
openSearch()
|
||||
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'nginx' } })
|
||||
expect(screen.getByText('Web Server')).toBeDefined()
|
||||
expect(screen.queryByText('DB Server')).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes groupRect nodes from results', () => {
|
||||
setupStore([
|
||||
makeNode('gr1', { data: { label: 'DMZ Zone', type: 'groupRect', status: 'unknown', services: [], ip: null, hostname: null } }),
|
||||
])
|
||||
render(<SearchBar />)
|
||||
openSearch()
|
||||
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'dmz' } })
|
||||
expect(screen.queryByText('DMZ Zone')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no-results message when query has no matches', () => {
|
||||
render(<SearchBar />)
|
||||
openSearch()
|
||||
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'zzznomatch' } })
|
||||
expect(screen.getByText(/no results/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls setSelectedNode when a result is clicked', () => {
|
||||
const setSelectedNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode('n1', { data: { label: 'My Server', type: 'server', status: 'online', services: [], ip: null, hostname: null } })],
|
||||
setSelectedNode,
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<SearchBar />)
|
||||
openSearch()
|
||||
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'my server' } })
|
||||
fireEvent.click(screen.getByText('My Server'))
|
||||
expect(setSelectedNode).toHaveBeenCalledWith('n1')
|
||||
})
|
||||
|
||||
it('shows result count', () => {
|
||||
setupStore([
|
||||
makeNode('n1', { data: { label: 'Alpha', type: 'server', status: 'online', services: [], ip: null, hostname: null } }),
|
||||
makeNode('n2', { data: { label: 'Beta', type: 'server', status: 'online', services: [], ip: null, hostname: null } }),
|
||||
])
|
||||
render(<SearchBar />)
|
||||
openSearch()
|
||||
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'a' } })
|
||||
expect(screen.getByText(/2 results/i)).toBeDefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user