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
91 changes: 91 additions & 0 deletions frontend/src/components/common/RemoveLinksModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { X } from 'lucide-react'
import { useEffect, useState } from 'react'

interface LinkedNodeItem {
path: string
title: string
}

interface RemoveLinksModalProps {
isOpen: boolean
linkedNodes: LinkedNodeItem[]
onClose: () => void
onRemoveSelected: (selectedPaths: string[]) => Promise<void> | void
initialSelected?: string[]
}

export default function RemoveLinksModal({ isOpen, linkedNodes, onClose, onRemoveSelected, initialSelected = [] }: RemoveLinksModalProps) {
const [selected, setSelected] = useState<Set<string>>(new Set(initialSelected))
const [busy, setBusy] = useState(false)

useEffect(() => {
// Reset selection when the modal is opened
if (isOpen) {
setSelected(new Set(initialSelected))
}
}, [isOpen])

if (!isOpen) return null

return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={onClose}>
<div className="bg-background border border-border rounded-lg w-[520px] max-w-[90vw]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold">Remove links</h3>
<button className="p-1.5 rounded hover:bg-accent" onClick={onClose} aria-label="Close">
<X className="w-4 h-4"/>
</button>
</div>
<div className="p-4">
<p className="text-sm text-muted-foreground mb-2">Select links to remove from this node:</p>
<div className="max-h-64 overflow-auto border border-border rounded">
<ul>
{linkedNodes.length === 0 && (
<li className="px-3 py-2 text-sm text-muted-foreground">No links</li>
)}
{linkedNodes.map(ln => (
<li key={ln.path} className="flex items-center gap-2 px-3 py-2 border-b last:border-b-0">
<input
type="checkbox"
checked={selected.has(ln.path)}
onChange={(e) => {
setSelected(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(ln.path); else next.delete(ln.path)
return next
})
}}
/>
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{ln.title}</div>
<div className="text-xs text-muted-foreground truncate">{ln.path}</div>
</div>
</li>
))}
</ul>
</div>
</div>
<div className="flex justify-between items-center gap-2 p-4 border-t">
<div className="text-xs text-muted-foreground">{selected.size} selected</div>
<div className="flex gap-2">
<button className="px-4 py-2 border border-input rounded hover:bg-accent" onClick={onClose} disabled={busy}>Cancel</button>
<button
className="px-4 py-2 bg-destructive text-destructive-foreground rounded hover:bg-destructive/90 disabled:opacity-50"
disabled={selected.size === 0 || busy}
onClick={async () => {
try {
setBusy(true)
await onRemoveSelected(Array.from(selected))
} finally {
setBusy(false)
}
}}
>Remove selected</button>
</div>
</div>
</div>
</div>
)
}


13 changes: 11 additions & 2 deletions frontend/src/components/graph/NodeContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { desktopTemplatesApi } from '../../api/desktop-templates'
import { templatesApi, Template } from '../../api/templates'
import { useProjectStore } from '../../store/projectStore'
import { Plus, Trash2, Edit, Link, FolderPlus, CheckSquare, Paperclip, Loader2, CheckCircle, Lock, Unlock, ChevronDown, ChevronRight } from 'lucide-react'
import { Plus, Trash2, Edit, Link, Unlink, FolderPlus, CheckSquare, Paperclip, Loader2, CheckCircle, Lock, Unlock, ChevronDown, ChevronRight } from 'lucide-react'

interface NodeContextMenuProps {
x: number
Expand All @@ -28,9 +28,10 @@ interface NodeContextMenuProps {
isLocked?: boolean
onToggleCollapse?: (nodeId: string) => void
isCollapsed?: boolean
onRemoveLinks?: (nodeId: string) => void
}

function NodeContextMenu({ x, y, nodeId, edgeId, isFolder, hasTask, onCreateNode, onDeleteNode, onCreateChildNode, onCreateChildFolder, onEditNode, onSeeTask, onUnlinkEdge, onAttachFiles, onUploadFiles, onDeleteMultiple, multiCount = 0, onClose, onToggleTrackTask, onToggleLock, isLocked, onToggleCollapse, isCollapsed }: NodeContextMenuProps) {
function NodeContextMenu({ x, y, nodeId, edgeId, isFolder, hasTask, onCreateNode, onDeleteNode, onCreateChildNode, onCreateChildFolder, onEditNode, onSeeTask, onUnlinkEdge, onAttachFiles, onUploadFiles, onDeleteMultiple, multiCount = 0, onClose, onToggleTrackTask, onToggleLock, isLocked, onToggleCollapse, isCollapsed, onRemoveLinks }: NodeContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
const { currentProject, currentProjectPath } = useProjectStore()
const [templates, setTemplates] = useState<Template[]>([])
Expand Down Expand Up @@ -245,6 +246,14 @@ function NodeContextMenu({ x, y, nodeId, edgeId, isFolder, hasTask, onCreateNode
Create Link
</button>

<button
onClick={() => { if (nodeId && onRemoveLinks) { onRemoveLinks(nodeId) } else { onClose() } }}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-accent hover:text-accent-foreground flex items-center gap-2"
>
<Unlink className="w-3 h-3" />
Remove Link(s)
</button>

{onToggleTrackTask && (
<button
onClick={() => {
Expand Down
85 changes: 22 additions & 63 deletions frontend/src/pages/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { FileStorage, StoredFile } from '../utils/fileStorage'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import SharedCreateLinkModal from '../components/common/CreateLinkModal'
import RemoveLinksModal from '../components/common/RemoveLinksModal'

// Check if we're in Electron
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
Expand Down Expand Up @@ -1408,69 +1409,27 @@ function EditorView() {
/>

{/* Bulk remove links dialog */}
{removeLinksOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={() => setRemoveLinksOpen(false)}>
<div className="bg-background border border-border rounded-lg w-[520px] max-w-[90vw]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold">Remove links</h3>
<button className="p-1.5 rounded hover:bg-accent" onClick={() => setRemoveLinksOpen(false)}><X className="w-4 h-4"/></button>
</div>
<div className="p-4">
<p className="text-sm text-muted-foreground mb-2">Select links to remove from this node:</p>
<div className="max-h-64 overflow-auto border border-border rounded">
<ul>
{linkedNodes.length === 0 && (
<li className="px-3 py-2 text-sm text-muted-foreground">No links</li>
)}
{linkedNodes.map(ln => (
<li key={ln.path} className="flex items-center gap-2 px-3 py-2 border-b last:border-b-0">
<input
type="checkbox"
checked={removeLinksSelected.has(ln.path)}
onChange={(e) => {
setRemoveLinksSelected(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(ln.path); else next.delete(ln.path)
return next
})
}}
/>
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{ln.title}</div>
<div className="text-xs text-muted-foreground truncate">{ln.path}</div>
</div>
</li>
))}
</ul>
</div>
</div>
<div className="flex justify-end gap-2 p-4 border-t">
<button className="px-4 py-2 border border-input rounded hover:bg-accent" onClick={() => { setRemoveLinksOpen(false); setRemoveLinksSelected(new Set()) }}>Cancel</button>
<button
className="px-4 py-2 bg-destructive text-destructive-foreground rounded hover:bg-destructive/90 disabled:opacity-50"
disabled={removeLinksSelected.size === 0}
onClick={async () => {
try {
const store = useNodeStore.getState()
const current = resolvedNodePathRef.current
if (!current) return
// Remove links sequentially
for (const p of Array.from(removeLinksSelected)) {
await store.removeSoftLink(current, p)
}
try { await store.loadNodes() } catch {}
setLinksExpanded(true)
await refreshCurrentEditorContent()
} finally {
setRemoveLinksOpen(false)
setRemoveLinksSelected(new Set())
}
}}
>Remove selected</button>
</div>
</div>
</div>
)}
<RemoveLinksModal
isOpen={removeLinksOpen}
linkedNodes={linkedNodes.map(ln => ({ path: ln.path, title: ln.title }))}
onClose={() => { setRemoveLinksOpen(false); setRemoveLinksSelected(new Set()) }}
onRemoveSelected={async (paths) => {
try {
const store = useNodeStore.getState()
const current = resolvedNodePathRef.current
if (!current) return
for (const p of paths) {
await store.removeSoftLink(current, p)
}
try { await store.loadNodes() } catch {}
setLinksExpanded(true)
await refreshCurrentEditorContent()
} finally {
setRemoveLinksOpen(false)
setRemoveLinksSelected(new Set())
}
}}
/>

{/* Hidden file input for attachments */}
<input
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/pages/Graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { templatesApi, Template as ApiTemplate } from '../api/templates';
import { apiClient } from '../api/client'
import CustomNode from '../components/graph/CustomNode'
import NodeContextMenu from '../components/graph/NodeContextMenu'
import RemoveLinksModal from '../components/common/RemoveLinksModal'
import { FileStorage, StoredFile } from '../utils/fileStorage'
import { Paperclip, Filter, ListTree, Loader2, LineChart, Network } from 'lucide-react'
import clsx from 'clsx'
Expand Down Expand Up @@ -83,6 +84,8 @@ function GraphView() {
const [isShiftMarquee, setIsShiftMarquee] = useState(false)
const [selectionBase, setSelectionBase] = useState<Set<string> | null>(null)
const [ctrlMetaPressed, setCtrlMetaPressed] = useState(false)
const [removeLinksOpen, setRemoveLinksOpen] = useState(false)
const [removeLinksNodePath, setRemoveLinksNodePath] = useState<string | null>(null)
const [hideUploads, setHideUploads] = useState<boolean>(() => {
try {
const raw = localStorage.getItem(STORAGE_KEYS.GRAPH_HIDE_UPLOADS)
Expand Down Expand Up @@ -1072,6 +1075,12 @@ function GraphView() {
[navigate, verbweaverNodes, addEditorTab]
)

const openRemoveLinksForNode = useCallback((nodePath: string) => {
setRemoveLinksNodePath(nodePath)
setRemoveLinksOpen(true)
setContextMenu(null)
}, [])

// Handle deleting edge
const handleDeleteEdge = useCallback(
async (edgeId: string) => {
Expand Down Expand Up @@ -1829,6 +1838,7 @@ function GraphView() {
const id = contextMenu.nodeId || ''
return !!graphCollapsed[id]
})()}
onRemoveLinks={(nodeId) => openRemoveLinksForNode(nodeId)}
onUploadFiles={() => {
const input = document.getElementById('graph-canvas-upload-input') as HTMLInputElement | null
input?.click()
Expand Down Expand Up @@ -1965,6 +1975,41 @@ function GraphView() {
parentPath={parentPathForNewNode}
/>

{/* Remove Links Modal for Mind Map */}
<RemoveLinksModal
isOpen={removeLinksOpen}
linkedNodes={(() => {
if (!removeLinksNodePath) return []
const source = verbweaverNodes.get(removeLinksNodePath)
if (!source || !Array.isArray(source.softLinks)) return []
const results: { path: string; title: string }[] = []
for (const targetId of source.softLinks) {
const target = Array.from(verbweaverNodes.values()).find(n => n.metadata?.id === targetId)
if (target) results.push({ path: target.path, title: target.metadata?.title || target.name })
}
// De-duplicate by path
const uniq = new Map<string, { path: string; title: string }>()
results.forEach(r => uniq.set(r.path, r))
return Array.from(uniq.values()).sort((a, b) => a.title.localeCompare(b.title))
})()}
onClose={() => { setRemoveLinksOpen(false); setRemoveLinksNodePath(null) }}
onRemoveSelected={async (paths) => {
try {
if (!removeLinksNodePath) return
for (const p of paths) {
await removeSoftLink(removeLinksNodePath, p)
}
await loadNodes()
toast.success('Link(s) removed')
} catch (e) {
toast.error('Failed to remove link(s)')
} finally {
setRemoveLinksOpen(false)
setRemoveLinksNodePath(null)
}
}}
/>

{/* Hidden file input for attachments */}
{attachTarget && (
<input
Expand Down