Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions frontend/src/components/canvas/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { ChevronRight } from 'lucide-react'
import { useEffect, useRef, type LucideIcon } from 'react'
import ReactDOM from 'react-dom'

export type MenuItem =
| { kind: 'action'; label: string; icon?: LucideIcon; danger?: boolean; onClick: () => void }
| { kind: 'submenu'; label: string; icon?: LucideIcon; items: MenuItem[] }
| { kind: 'separator' }

interface ContextMenuProps {
x: number
y: number
items: MenuItem[]
onClose: () => void
}

function MenuItems({ items, onClose }: { items: MenuItem[]; onClose: () => void }) {
return (
<>
{items.map((item, i) => {
if (item.kind === 'separator') {
return <div key={i} className="my-1 border-t border-terminal-600" />
}
if (item.kind === 'submenu') {
const Icon = item.icon
return (
<div key={i}>
<div className="flex items-center gap-2 px-3 py-1.5 text-sm text-terminal-300 select-none">
{Icon && <Icon className="h-3.5 w-3.5 shrink-0 text-terminal-400" />}
<span>{item.label}</span>
<ChevronRight className="ml-auto h-3 w-3 text-terminal-500" />
</div>
<div className="border-t border-terminal-700 pb-1">
{item.items.map((sub, j) => {
if (sub.kind === 'action') {
const SubIcon = sub.icon
return (
<button
key={j}
onClick={() => {
sub.onClick()
onClose()
}}
className={[
'flex w-full items-center gap-2 px-6 py-1.5 text-sm',
'cursor-pointer text-left',
'hover:bg-terminal-700 focus:bg-terminal-700 focus:outline-none',
sub.danger ? 'text-red-400' : 'text-terminal-200',
].join(' ')}
>
{SubIcon && <SubIcon className="h-3.5 w-3.5 shrink-0" />}
{sub.label}
</button>
)
}
return null
})}
</div>
</div>
)
}
// kind === 'action'
const Icon = item.icon
return (
<button
key={i}
onClick={() => {
item.onClick()
onClose()
}}
className={[
'flex w-full items-center gap-2 px-3 py-1.5 text-sm',
'cursor-pointer text-left',
'hover:bg-terminal-700 focus:bg-terminal-700 focus:outline-none',
item.danger ? 'text-red-400' : 'text-terminal-200',
].join(' ')}
>
{Icon && <Icon className="h-3.5 w-3.5 shrink-0" />}
{item.label}
</button>
)
})}
</>
)
}

export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)

useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
}
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
const handleScroll = () => onClose()

document.addEventListener('mousedown', handleMouseDown)
document.addEventListener('keydown', handleKeyDown)
window.addEventListener('scroll', handleScroll, true)
return () => {
document.removeEventListener('mousedown', handleMouseDown)
document.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('scroll', handleScroll, true)
}
}, [onClose])

// Clamp to viewport so the menu never renders off-screen
const vpW = window.innerWidth
const vpH = window.innerHeight
const menuW = 192 // min-w-48
const menuH = 300 // generous estimate; real height unknown until rendered
const left = Math.min(x, vpW - menuW - 8)
const top = Math.min(y, vpH - menuH - 8)

const menu = (
<div
ref={menuRef}
style={{ position: 'fixed', top, left, zIndex: 9999 }}
className="min-w-48 rounded-md border border-terminal-600 bg-terminal-800 py-1 shadow-lg shadow-black/40"
onContextMenu={(e) => e.preventDefault()}
>
<MenuItems items={items} onClose={onClose} />
</div>
)

return ReactDOM.createPortal(menu, document.body)
}
84 changes: 50 additions & 34 deletions frontend/src/components/detail/EdgePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { ArrowRight } from 'lucide-react'
import { ArrowRight, Trash2 } from 'lucide-react'
import { useCanvasStore } from '../../store/canvasStore'
import { NODE_TYPE_META } from '../nodes/constants'

export function EdgePanel() {
const selectedEdgeId = useCanvasStore((s) => s.selectedEdgeId)
const edges = useCanvasStore((s) => s.edges)
const nodes = useCanvasStore((s) => s.nodes)
const deleteEdge = useCanvasStore((s) => s.deleteEdge)
const clearSelection = useCanvasStore((s) => s.clearSelection)

const edge = edges.find((e) => e.id === selectedEdgeId)
if (!edge) return null
Expand All @@ -15,41 +17,55 @@ export function EdgePanel() {
const isResource = edge.type === 'resource'

return (
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
{/* Edge category badge */}
<div className="flex items-center gap-2">
<span
className={[
'rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider',
isResource
? 'border-terminal-500 text-terminal-300'
: 'border-magi-blue-800 text-magi-blue-400',
].join(' ')}
>
{isResource ? 'Resource' : 'Data'}
</span>
<span className="text-[11px] text-terminal-400">
{isResource ? 'Static capability binding' : 'Runtime message flow'}
</span>
</div>
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
{/* Edge category badge */}
<div className="flex items-center gap-2">
<span
className={[
'rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider',
isResource
? 'border-terminal-500 text-terminal-300'
: 'border-magi-blue-800 text-magi-blue-400',
].join(' ')}
>
{isResource ? 'Resource' : 'Data'}
</span>
<span className="text-[11px] text-terminal-400">
{isResource ? 'Static capability binding' : 'Runtime message flow'}
</span>
</div>

{/* Source → Target */}
<div className="rounded-lg border border-terminal-500 bg-terminal-800/50 p-3">
<NodePortRow
label="Source"
nodeName={sourceNode?.data.label ?? edge.source}
nodeType={sourceNode?.type ?? ''}
portName={edge.sourceHandle ?? '—'}
/>
<div className="my-2 flex items-center justify-center">
<ArrowRight className="h-3.5 w-3.5 text-terminal-500" />
{/* Source → Target */}
<div className="rounded-lg border border-terminal-500 bg-terminal-800/50 p-3">
<NodePortRow
label="Source"
nodeName={sourceNode?.data.label ?? edge.source}
nodeType={sourceNode?.type ?? ''}
portName={edge.sourceHandle ?? '—'}
/>
<div className="my-2 flex items-center justify-center">
<ArrowRight className="h-3.5 w-3.5 text-terminal-500" />
</div>
<NodePortRow
label="Target"
nodeName={targetNode?.data.label ?? edge.target}
nodeType={targetNode?.type ?? ''}
portName={edge.targetHandle ?? '—'}
/>
</div>
<NodePortRow
label="Target"
nodeName={targetNode?.data.label ?? edge.target}
nodeType={targetNode?.type ?? ''}
portName={edge.targetHandle ?? '—'}
/>
</div>

{/* Delete action */}
<div className="border-t border-terminal-600 p-3">
<button
onClick={() => { deleteEdge(edge.id); clearSelection() }}
className="flex w-full items-center justify-center gap-2 rounded border border-terminal-600 py-1.5 text-sm text-terminal-400 transition-colors hover:border-red-800 hover:text-red-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-red-500"
aria-label="Delete edge"
>
<Trash2 className="h-3.5 w-3.5" />
Delete edge
</button>
</div>
</div>
)
Expand Down
Loading
Loading