@skyhook-io/radar-app 1.14.4 → 1.14.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -9
- package/package.json +6 -6
- package/src/App.tsx +91 -328
- package/src/RadarApp.tsx +3 -8
- package/src/api/client.jobset.test.ts +18 -0
- package/src/api/client.resource-identity.test.ts +44 -0
- package/src/api/client.rightsizing.test.ts +7 -3
- package/src/api/client.ts +233 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +14 -8
- package/src/components/ContextSwitcher.tsx +13 -132
- package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
- package/src/components/applications/ApplicationsView.tsx +5 -3
- package/src/components/audit/AuditSettingsDialog.tsx +15 -11
- package/src/components/diagnose/AIMarkdown.tsx +8 -3
- package/src/components/diagnose/AgentControls.tsx +26 -3
- package/src/components/diagnose/AssessmentCard.tsx +249 -237
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +47 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +15 -2
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +1 -1
- package/src/components/diagnose/InvestigationView.tsx +4 -1
- package/src/components/diagnose/agentCatalog.ts +6 -0
- package/src/components/diagnose/investigationCase.test.tsx +56 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +60 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.ts +7 -3
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +1 -1
- package/src/components/diagnose/investigationEvidence/observations.ts +8 -5
- package/src/components/diagnose/investigationEvidence/types.ts +1 -1
- package/src/components/diagnose/launch.test.ts +23 -0
- package/src/components/diagnose/launch.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +159 -2
- package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
- package/src/components/dock/NodeTerminalTab.tsx +5 -3
- package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
- package/src/components/dock/WorkloadLogsTab.tsx +2 -1
- package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
- package/src/components/execution/BatchExecutionView.test.ts +52 -1
- package/src/components/execution/BatchExecutionView.tsx +324 -65
- package/src/components/execution/JobSetAdmission.test.tsx +42 -0
- package/src/components/execution/JobSetAdmission.tsx +17 -0
- package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
- package/src/components/execution/JobSetMemberComparison.tsx +88 -0
- package/src/components/execution/batch-run-actions.test.ts +1 -0
- package/src/components/execution/batch-timeline.test.ts +3 -0
- package/src/components/execution/execution-definition.test.ts +15 -1
- package/src/components/execution/execution-definition.ts +4 -0
- package/src/components/execution/member-collection.render.test.tsx +142 -0
- package/src/components/gitops/GitOpsView.tsx +33 -2
- package/src/components/gitops/destination-toast.test.ts +33 -0
- package/src/components/gitops/destination-toast.ts +46 -0
- package/src/components/gitops/useDestinationCluster.ts +30 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.tsx +2 -5
- package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
- package/src/components/resource/PVCUsageBar.tsx +62 -16
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +3 -3
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +207 -83
- package/src/components/rightsizing/copy.test.ts +7 -0
- package/src/components/rightsizing/empty-state.test.tsx +67 -0
- package/src/components/rightsizing/model.test.ts +120 -2
- package/src/components/rightsizing/model.ts +36 -13
- package/src/components/rightsizing/notices.test.tsx +176 -0
- package/src/components/settings/OperatorManagedNotice.tsx +17 -0
- package/src/components/settings/SettingsDialog.tsx +157 -42
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +18 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/Markdown.tsx +33 -6
- package/src/components/ui/command-items.ts +0 -3
- package/src/components/useContextSwitchFlow.tsx +147 -0
- package/src/components/workload/WorkloadView.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +36 -18
- package/src/context/ContextSwitchContext.tsx +18 -2
- package/src/context/NavCustomization.tsx +4 -58
- package/src/context/ThemeContext.tsx +3 -11
- package/src/hooks/useClusterLoadState.ts +2 -2
- package/src/hooks/useFavorites.ts +3 -5
- package/src/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +18 -1
- package/src/utils/navigation.ts +12 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- package/src/components/ui/CommandPalette.tsx +0 -261
|
@@ -37,4 +37,60 @@ describe('findSelectedTopologyNode', () => {
|
|
|
37
37
|
|
|
38
38
|
expect(findSelectedTopologyNode(topology, selected('two.example', 'widgets'))?.id).toBe('second')
|
|
39
39
|
})
|
|
40
|
+
|
|
41
|
+
it('matches a pseudo topology kind through its real Kubernetes kind', () => {
|
|
42
|
+
const knative = node('knative', 'KnativeService', 'api', 'serving.knative.dev/v1')
|
|
43
|
+
knative.data.resourceKind = 'Service'
|
|
44
|
+
|
|
45
|
+
expect(findSelectedTopologyNode(
|
|
46
|
+
{ nodes: [knative], edges: [] },
|
|
47
|
+
{ kind: 'services', namespace: 'default', name: 'api', group: 'serving.knative.dev' },
|
|
48
|
+
)?.id).toBe('knative')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('keeps an omitted or explicit core Service selection on the core node', () => {
|
|
52
|
+
const core = node('core', 'Service', 'api')
|
|
53
|
+
const knative = node('knative', 'KnativeService', 'api', 'serving.knative.dev/v1')
|
|
54
|
+
knative.data.resourceKind = 'Service'
|
|
55
|
+
const topology: Topology = { nodes: [knative, core], edges: [] }
|
|
56
|
+
|
|
57
|
+
expect(findSelectedTopologyNode(topology, {
|
|
58
|
+
kind: 'services', namespace: 'default', name: 'api',
|
|
59
|
+
})?.id).toBe('core')
|
|
60
|
+
expect(findSelectedTopologyNode(topology, {
|
|
61
|
+
kind: 'services', namespace: 'default', name: 'api', group: '',
|
|
62
|
+
})?.id).toBe('core')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('infers a typed Job group when its topology node omits apiVersion', () => {
|
|
66
|
+
const core = node('core', 'Job', 'train')
|
|
67
|
+
const volcano = node('volcano', 'Job', 'train', 'batch.volcano.sh/v1alpha1')
|
|
68
|
+
const topology: Topology = { nodes: [volcano, core], edges: [] }
|
|
69
|
+
|
|
70
|
+
expect(findSelectedTopologyNode(topology, {
|
|
71
|
+
kind: 'jobs', namespace: 'default', name: 'train', group: 'batch',
|
|
72
|
+
})?.id).toBe('core')
|
|
73
|
+
expect(findSelectedTopologyNode(topology, {
|
|
74
|
+
kind: 'jobs', namespace: 'default', name: 'train', group: 'batch.volcano.sh',
|
|
75
|
+
})?.id).toBe('volcano')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('infers the policy group for a typed PodDisruptionBudget node', () => {
|
|
79
|
+
const pdb = node('pdb', 'PodDisruptionBudget', 'budget')
|
|
80
|
+
|
|
81
|
+
expect(findSelectedTopologyNode(
|
|
82
|
+
{ nodes: [pdb], edges: [] },
|
|
83
|
+
{ kind: 'poddisruptionbudgets', namespace: 'default', name: 'budget', group: 'policy' },
|
|
84
|
+
)?.id).toBe('pdb')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('fails closed for an omitted unknown group when custom resources collide', () => {
|
|
88
|
+
const one = node('one', 'Widget', 'same', 'one.example/v1')
|
|
89
|
+
const two = node('two', 'Widget', 'same', 'two.example/v1')
|
|
90
|
+
|
|
91
|
+
expect(findSelectedTopologyNode(
|
|
92
|
+
{ nodes: [one, two], edges: [] },
|
|
93
|
+
{ kind: 'widgets', namespace: 'default', name: 'same' },
|
|
94
|
+
)).toBeUndefined()
|
|
95
|
+
})
|
|
40
96
|
})
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import type { SelectedResource, Topology, TopologyNode } from '@skyhook-io/k8s-ui/types/core'
|
|
2
|
+
import { builtinGroupForKind, canonicalResourceGroup } from '@skyhook-io/k8s-ui/utils/api-resources'
|
|
2
3
|
import { apiVersionToGroup, kindToPluralWithGroup } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
4
|
+
import { topologyNodeResourceKind } from '@skyhook-io/k8s-ui/utils/topology-neighborhood'
|
|
3
5
|
|
|
4
6
|
function topologyNodeGroup(node: TopologyNode): string | undefined {
|
|
5
|
-
const
|
|
6
|
-
if (
|
|
7
|
+
const apiVersion = node.data.apiVersion
|
|
8
|
+
if (typeof apiVersion === 'string' && apiVersion) return apiVersionToGroup(apiVersion)
|
|
7
9
|
|
|
8
10
|
const sourceGroup = node.data.sourceGroup
|
|
9
|
-
if (typeof sourceGroup === 'string'
|
|
11
|
+
if (typeof sourceGroup === 'string') return sourceGroup
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
if (node.kind === 'NetworkPolicy') return 'networking.k8s.io'
|
|
13
|
-
return undefined
|
|
13
|
+
return builtinGroupForKind(topologyNodeResourceKind(node))
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function findSelectedTopologyNode(
|
|
@@ -22,13 +22,13 @@ export function findSelectedTopologyNode(
|
|
|
22
22
|
const candidates = topology?.nodes.filter(node =>
|
|
23
23
|
((node.data.namespace as string) || '') === namespace &&
|
|
24
24
|
node.name === selectedResource.name &&
|
|
25
|
-
(kindToPluralWithGroup(node
|
|
25
|
+
(kindToPluralWithGroup(topologyNodeResourceKind(node), topologyNodeGroup(node) ?? '') === selectedKind || topologyNodeResourceKind(node) === selectedResource.kind),
|
|
26
26
|
) ?? []
|
|
27
27
|
|
|
28
28
|
if (candidates.length === 0) return undefined
|
|
29
29
|
|
|
30
|
-
const selectedGroup = selectedResource.group
|
|
31
|
-
if (
|
|
30
|
+
const selectedGroup = canonicalResourceGroup(selectedResource.kind, selectedResource.group)
|
|
31
|
+
if (selectedGroup === undefined) return candidates.length === 1 ? candidates[0] : undefined
|
|
32
32
|
|
|
33
33
|
const groupMatch = candidates.find(node => topologyNodeGroup(node) === selectedGroup)
|
|
34
34
|
if (groupMatch) return groupMatch
|
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
|
2
|
-
import { TRANSITION_BACKDROP, TRANSITION_PANEL, overlayTransitionStyle } from '../../utils/animation'
|
|
3
|
-
import { Search, X, ChevronRight } from 'lucide-react'
|
|
4
|
-
import { clsx } from 'clsx'
|
|
5
|
-
import { useCommandItems, bestScore, type CommandItem, type CommandItemCallbacks } from './command-items'
|
|
6
|
-
import { Input } from '@skyhook-io/k8s-ui'
|
|
7
|
-
|
|
8
|
-
interface CommandPaletteProps extends CommandItemCallbacks {
|
|
9
|
-
onClose: () => void
|
|
10
|
-
/** Controls fade-in/out animation (driven by useAnimatedUnmount) */
|
|
11
|
-
isOpen?: boolean
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export function CommandPalette({ onClose, isOpen = true, ...callbacks }: CommandPaletteProps) {
|
|
16
|
-
const [query, setQuery] = useState('')
|
|
17
|
-
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
18
|
-
const inputRef = useRef<HTMLInputElement>(null)
|
|
19
|
-
const resultsRef = useRef<HTMLDivElement>(null)
|
|
20
|
-
const isKeyboardNav = useRef(false)
|
|
21
|
-
|
|
22
|
-
// The static command items (Views / Kinds / Namespaces / Actions) — shared
|
|
23
|
-
// with the standalone omnibar via useCommandItems so the two never drift.
|
|
24
|
-
const items = useCommandItems(callbacks)
|
|
25
|
-
|
|
26
|
-
// Focus the input once logically open. Not on mount: the presence hook
|
|
27
|
-
// mounts the palette two frames before `isOpen` flips, and the root is
|
|
28
|
-
// `inert` until then, so a mount-time focus would be refused.
|
|
29
|
-
useEffect(() => {
|
|
30
|
-
if (isOpen) inputRef.current?.focus()
|
|
31
|
-
}, [isOpen])
|
|
32
|
-
|
|
33
|
-
// Close on Escape (capture phase to beat the shortcut system) — only while
|
|
34
|
-
// logically open: the palette stays mounted through its exit.
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
if (!isOpen) return
|
|
37
|
-
const handler = (e: KeyboardEvent) => {
|
|
38
|
-
if (e.key === 'Escape') {
|
|
39
|
-
e.preventDefault()
|
|
40
|
-
e.stopPropagation()
|
|
41
|
-
onClose()
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
document.addEventListener('keydown', handler, true)
|
|
45
|
-
return () => document.removeEventListener('keydown', handler, true)
|
|
46
|
-
}, [isOpen, onClose])
|
|
47
|
-
|
|
48
|
-
// Filter and rank results
|
|
49
|
-
const filteredItems = useMemo(() => {
|
|
50
|
-
if (!query.trim()) {
|
|
51
|
-
// Show views and actions when no query
|
|
52
|
-
return items.filter(i => i.category === 'Views' || i.category === 'Actions')
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return items
|
|
56
|
-
.map(item => {
|
|
57
|
-
let score = bestScore(item, query)
|
|
58
|
-
// Boost core K8s resources over CRDs at the same match quality
|
|
59
|
-
if (score > 0 && item.category === 'Resource Kinds' && item.sublabel === 'core') {
|
|
60
|
-
score += 10
|
|
61
|
-
}
|
|
62
|
-
return { item, score }
|
|
63
|
-
})
|
|
64
|
-
.filter(({ score }) => score > 0)
|
|
65
|
-
.sort((a, b) => b.score - a.score)
|
|
66
|
-
.slice(0, 20)
|
|
67
|
-
.map(({ item }) => item)
|
|
68
|
-
}, [items, query])
|
|
69
|
-
|
|
70
|
-
// Group filtered items by category
|
|
71
|
-
const grouped = useMemo(() => {
|
|
72
|
-
const groups = new Map<string, CommandItem[]>()
|
|
73
|
-
for (const item of filteredItems) {
|
|
74
|
-
const list = groups.get(item.category) || []
|
|
75
|
-
list.push(item)
|
|
76
|
-
groups.set(item.category, list)
|
|
77
|
-
}
|
|
78
|
-
return groups
|
|
79
|
-
}, [filteredItems])
|
|
80
|
-
|
|
81
|
-
// Single source of truth for flat ordering — derived from grouped to match render order
|
|
82
|
-
const flatItems = useMemo(() => {
|
|
83
|
-
const result: CommandItem[] = []
|
|
84
|
-
for (const [, categoryItems] of grouped) {
|
|
85
|
-
result.push(...categoryItems)
|
|
86
|
-
}
|
|
87
|
-
return result
|
|
88
|
-
}, [grouped])
|
|
89
|
-
|
|
90
|
-
// Reset selection when results change
|
|
91
|
-
useEffect(() => {
|
|
92
|
-
setSelectedIndex(0)
|
|
93
|
-
}, [flatItems.length, query])
|
|
94
|
-
|
|
95
|
-
// Scroll selected into view
|
|
96
|
-
useEffect(() => {
|
|
97
|
-
if (resultsRef.current && selectedIndex >= 0) {
|
|
98
|
-
const el = resultsRef.current.querySelector('[data-selected="true"]') as HTMLElement
|
|
99
|
-
el?.scrollIntoView({ block: 'nearest' })
|
|
100
|
-
}
|
|
101
|
-
}, [selectedIndex])
|
|
102
|
-
|
|
103
|
-
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
104
|
-
switch (e.key) {
|
|
105
|
-
case 'ArrowDown':
|
|
106
|
-
e.preventDefault()
|
|
107
|
-
isKeyboardNav.current = true
|
|
108
|
-
setSelectedIndex(i => Math.min(i + 1, flatItems.length - 1))
|
|
109
|
-
break
|
|
110
|
-
case 'ArrowUp':
|
|
111
|
-
e.preventDefault()
|
|
112
|
-
isKeyboardNav.current = true
|
|
113
|
-
setSelectedIndex(i => Math.max(i - 1, 0))
|
|
114
|
-
break
|
|
115
|
-
case 'Enter':
|
|
116
|
-
e.preventDefault()
|
|
117
|
-
if (flatItems[selectedIndex]) {
|
|
118
|
-
flatItems[selectedIndex].action()
|
|
119
|
-
onClose()
|
|
120
|
-
}
|
|
121
|
-
break
|
|
122
|
-
}
|
|
123
|
-
}, [flatItems, selectedIndex, onClose])
|
|
124
|
-
|
|
125
|
-
// Flatten for index tracking
|
|
126
|
-
let flatIndex = 0
|
|
127
|
-
|
|
128
|
-
return (
|
|
129
|
-
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]" inert={!isOpen || undefined}>
|
|
130
|
-
{/* Backdrop */}
|
|
131
|
-
<div
|
|
132
|
-
className={clsx(
|
|
133
|
-
'absolute inset-0 bg-theme-base/60 backdrop-blur-sm',
|
|
134
|
-
TRANSITION_BACKDROP,
|
|
135
|
-
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
|
136
|
-
)}
|
|
137
|
-
style={overlayTransitionStyle(isOpen, 'dialog')}
|
|
138
|
-
onClick={isOpen ? onClose : undefined}
|
|
139
|
-
/>
|
|
140
|
-
|
|
141
|
-
{/* Panel */}
|
|
142
|
-
<div
|
|
143
|
-
className={clsx(
|
|
144
|
-
'relative w-full max-w-lg mx-4 dialog overflow-hidden',
|
|
145
|
-
TRANSITION_PANEL,
|
|
146
|
-
isOpen ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-[0.97] translate-y-3'
|
|
147
|
-
)}
|
|
148
|
-
style={overlayTransitionStyle(isOpen, 'dialog')}
|
|
149
|
-
>
|
|
150
|
-
{/* Search input */}
|
|
151
|
-
<div className="flex items-center gap-3 px-4 py-3 border-b border-theme-border">
|
|
152
|
-
<Search className="w-5 h-5 text-theme-text-secondary shrink-0" />
|
|
153
|
-
<Input
|
|
154
|
-
ref={inputRef}
|
|
155
|
-
value={query}
|
|
156
|
-
onChange={(e) => setQuery(e.target.value)}
|
|
157
|
-
onKeyDown={handleKeyDown}
|
|
158
|
-
placeholder="Type a command or search..."
|
|
159
|
-
className="flex-1 bg-transparent text-theme-text-primary placeholder-theme-text-disabled outline-none text-sm"
|
|
160
|
-
autoFocus
|
|
161
|
-
/>
|
|
162
|
-
{query && (
|
|
163
|
-
<button
|
|
164
|
-
onClick={() => setQuery('')}
|
|
165
|
-
className="p-1 text-theme-text-secondary hover:text-theme-text-primary"
|
|
166
|
-
>
|
|
167
|
-
<X className="w-4 h-4" />
|
|
168
|
-
</button>
|
|
169
|
-
)}
|
|
170
|
-
<kbd className="px-1.5 py-0.5 text-xs text-theme-text-tertiary bg-theme-elevated rounded border border-theme-border-light">
|
|
171
|
-
ESC
|
|
172
|
-
</kbd>
|
|
173
|
-
</div>
|
|
174
|
-
|
|
175
|
-
{/* Results */}
|
|
176
|
-
<div ref={resultsRef} className="max-h-[50vh] overflow-y-auto">
|
|
177
|
-
{query && filteredItems.length === 0 && (
|
|
178
|
-
<div className="px-4 py-8 text-center text-theme-text-tertiary">
|
|
179
|
-
<Search className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
|
180
|
-
<p>No results for "{query}"</p>
|
|
181
|
-
</div>
|
|
182
|
-
)}
|
|
183
|
-
|
|
184
|
-
{Array.from(grouped.entries()).map(([category, categoryItems]) => (
|
|
185
|
-
<div key={category}>
|
|
186
|
-
<div className="px-4 py-1.5 text-[10px] font-semibold text-theme-text-tertiary uppercase tracking-wider bg-theme-surface/50 sticky top-0">
|
|
187
|
-
{category}
|
|
188
|
-
</div>
|
|
189
|
-
{categoryItems.map((item) => {
|
|
190
|
-
const thisIndex = flatIndex++
|
|
191
|
-
const isSelected = thisIndex === selectedIndex
|
|
192
|
-
const Icon = item.icon
|
|
193
|
-
|
|
194
|
-
return (
|
|
195
|
-
<button
|
|
196
|
-
key={item.id}
|
|
197
|
-
data-selected={isSelected}
|
|
198
|
-
onClick={() => { item.action(); onClose() }}
|
|
199
|
-
onMouseMove={() => {
|
|
200
|
-
if (isKeyboardNav.current) { isKeyboardNav.current = false; return }
|
|
201
|
-
setSelectedIndex(thisIndex)
|
|
202
|
-
}}
|
|
203
|
-
className={clsx(
|
|
204
|
-
'w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors',
|
|
205
|
-
isSelected ? 'selection' : 'hover:bg-theme-elevated/30'
|
|
206
|
-
)}
|
|
207
|
-
>
|
|
208
|
-
{Icon && (
|
|
209
|
-
<div className={clsx(
|
|
210
|
-
'flex items-center justify-center w-7 h-7 rounded-md shrink-0',
|
|
211
|
-
isSelected ? 'selection-strong' : 'bg-theme-elevated/50'
|
|
212
|
-
)}>
|
|
213
|
-
<Icon className="w-3.5 h-3.5 text-theme-text-secondary" />
|
|
214
|
-
</div>
|
|
215
|
-
)}
|
|
216
|
-
{!Icon && <div className="w-7 h-7 shrink-0" />}
|
|
217
|
-
|
|
218
|
-
<div className="flex-1 min-w-0">
|
|
219
|
-
<span className="text-sm text-theme-text-primary truncate block">{item.label}</span>
|
|
220
|
-
{item.sublabel && (
|
|
221
|
-
<span className="text-xs text-theme-text-tertiary truncate block">{item.sublabel}</span>
|
|
222
|
-
)}
|
|
223
|
-
</div>
|
|
224
|
-
|
|
225
|
-
{item.shortcut && (
|
|
226
|
-
<kbd className="px-1.5 py-0.5 text-xs text-theme-text-tertiary bg-theme-elevated rounded border border-theme-border-light shrink-0">
|
|
227
|
-
{item.shortcut}
|
|
228
|
-
</kbd>
|
|
229
|
-
)}
|
|
230
|
-
|
|
231
|
-
<ChevronRight className={clsx(
|
|
232
|
-
'w-3.5 h-3.5 shrink-0 transition-opacity',
|
|
233
|
-
isSelected ? 'text-theme-text-secondary opacity-100' : 'opacity-0'
|
|
234
|
-
)} />
|
|
235
|
-
</button>
|
|
236
|
-
)
|
|
237
|
-
})}
|
|
238
|
-
</div>
|
|
239
|
-
))}
|
|
240
|
-
</div>
|
|
241
|
-
|
|
242
|
-
{/* Footer hints */}
|
|
243
|
-
{filteredItems.length > 0 && (
|
|
244
|
-
<div className="px-4 py-2 border-t border-theme-border bg-theme-surface/50">
|
|
245
|
-
<div className="flex items-center gap-4 text-xs text-theme-text-tertiary">
|
|
246
|
-
<span className="flex items-center gap-1">
|
|
247
|
-
<kbd className="px-1 py-0.5 bg-theme-elevated rounded">↑</kbd>
|
|
248
|
-
<kbd className="px-1 py-0.5 bg-theme-elevated rounded">↓</kbd>
|
|
249
|
-
Navigate
|
|
250
|
-
</span>
|
|
251
|
-
<span className="flex items-center gap-1">
|
|
252
|
-
<kbd className="px-1.5 py-0.5 bg-theme-elevated rounded">↵</kbd>
|
|
253
|
-
Select
|
|
254
|
-
</span>
|
|
255
|
-
</div>
|
|
256
|
-
</div>
|
|
257
|
-
)}
|
|
258
|
-
</div>
|
|
259
|
-
</div>
|
|
260
|
-
)
|
|
261
|
-
}
|