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
37 changes: 32 additions & 5 deletions backend/app/services/node_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,14 +264,41 @@ async def delete_node(self, path: str) -> None:
try:
node_to_delete = await self.read_node(path)
target_id = node_to_delete['metadata'].get('id') if node_to_delete else None
if target_id:
# Build a removal set including id and path variants
norm_path = str(path or '').replace('\\', '/')
with_md = norm_path if norm_path.endswith('.md') else f"{norm_path}.md"
without_md = norm_path[:-3] if norm_path.endswith('.md') else norm_path
remove_values = {v for v in [target_id, norm_path, with_md, without_md] if v}

if remove_values:
all_nodes = await self.list_nodes()
for other in all_nodes:
if other['path'] == path:
if other.get('path') == path:
continue
raw_links = other.get('metadata', {}).get('links', []) or []
if not isinstance(raw_links, list):
continue
other_links = other['metadata'].get('links', [])
if target_id in other_links:
cleaned_links = [lid for lid in other_links if lid != target_id]
def should_keep(val: Any) -> bool:
try:
s = str(val or '')
if not s:
return False
if s in remove_values:
return False
s_norm = s.replace('\\', '/')
if s_norm in remove_values:
return False
s_with_md = s_norm if s_norm.endswith('.md') else f"{s_norm}.md"
if s_with_md in remove_values:
return False
s_without_md = s_norm[:-3] if s_norm.endswith('.md') else s_norm
if s_without_md in remove_values:
return False
return True
except Exception:
return True
cleaned_links = [v for v in raw_links if should_keep(v)]
if len(cleaned_links) != len(raw_links):
await self.update_node(other['path'], {'links': cleaned_links})
except Exception:
# Don't block deletion if cleanup fails
Expand Down
31 changes: 27 additions & 4 deletions desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1961,9 +1961,19 @@ function setupIpcHandlers() {
return out;
};

// If we know the deleted node's id, scan other nodes and remove backlinks
if (deletedNodeId) {
// Scan other nodes and remove backlinks by ID and path variants
{
const nodesDir = path.join(projectPath, 'nodes');
const normRel = normalizedRel;
const relWithMd = /\.md$/i.test(normRel) ? normRel : `${normRel}.md`;
const relWithoutMd = /\.md$/i.test(normRel) ? normRel.slice(0, -3) : normRel;
const removalSet = new Set<string>([
...(deletedNodeId ? [deletedNodeId] : []),
normRel,
relWithMd,
relWithoutMd,
].filter(Boolean) as string[]);

const walk = async (dir: string) => {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
Expand All @@ -1979,8 +1989,21 @@ function setupIpcHandlers() {
const parsed = matter(fc);
const fm = (parsed.data || {}) as any;
const links: any[] = Array.isArray(fm.links) ? fm.links : [];
if (links.includes(deletedNodeId)) {
const newLinks = links.filter((l: any) => l !== deletedNodeId);
const newLinks = links.filter((l: any) => {
try {
const s = String(l || '');
if (!s) return false; // drop empty
if (removalSet.has(s)) return false;
const sNorm = s.replace(/\\/g, '/');
if (removalSet.has(sNorm)) return false;
const sWith = /\.md$/i.test(sNorm) ? sNorm : `${sNorm}.md`;
if (removalSet.has(sWith)) return false;
const sWithout = /\.md$/i.test(sNorm) ? sNorm.slice(0, -3) : sNorm;
if (removalSet.has(sWithout)) return false;
return true;
} catch { return true; }
});
if (newLinks.length !== links.length) {
const newFrontmatter = removeUndefined({ ...fm, links: newLinks });
const newContent = matter.stringify(parsed.content || '', newFrontmatter);
await fs.writeFile(full, newContent, 'utf8');
Expand Down
30 changes: 28 additions & 2 deletions frontend/src/components/tasks/TaskDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -701,9 +701,13 @@ function TaskDetailModal({ node, onClose, onUpdate, availableStatuses, columns,
const s = String(v || '').replace(/\\/g,'/')
return s !== ln.metadata.id && s !== ln.path.replace(/\\/g,'/')
})
const updatedSoftLinks = Array.isArray(node.softLinks)
? node.softLinks.filter((id: string) => id !== ln.metadata.id)
: []
const updatedNode = {
...node,
metadata: { ...node.metadata, links: updatedLinks }
metadata: { ...node.metadata, links: updatedLinks },
softLinks: updatedSoftLinks
}
onUpdate(updatedNode)
toast.success('Link removed')
Expand Down Expand Up @@ -833,7 +837,29 @@ function TaskDetailModal({ node, onClose, onUpdate, availableStatuses, columns,
onLinkCreated={(updatedLinks) => {
setIsCreateLinkModalOpen(false)
if (updatedLinks) {
const updatedNode = { ...node, metadata: { ...node.metadata, links: updatedLinks } } as any
// Resolve returned links (may be IDs or paths) into normalized softLinks (IDs)
const pathToId = new Map<string, string>()
const idSet = new Set<string>()
for (const other of nodesMap.values()) {
const p = String(other.path || '').replace(/\\/g, '/')
const nid = String(other.metadata?.id || '')
if (p) pathToId.set(p, nid)
if (nid) idSet.add(nid)
}
const resolvedSoftLinks: string[] = []
for (const entry of (Array.isArray(updatedLinks) ? updatedLinks : [])) {
const raw = String(entry || '')
if (!raw) continue
if (idSet.has(raw)) {
if (!resolvedSoftLinks.includes(raw)) resolvedSoftLinks.push(raw)
continue
}
const norm = raw.replace(/\\/g, '/')
const withMd = /\.md$/i.test(norm) ? norm : `${norm}.md`
const tid = pathToId.get(norm) || pathToId.get(withMd)
if (tid && !resolvedSoftLinks.includes(tid)) resolvedSoftLinks.push(tid)
}
const updatedNode = { ...node, metadata: { ...node.metadata, links: updatedLinks }, softLinks: resolvedSoftLinks } as any
onUpdate(updatedNode)
} else {
onUpdate({ ...(node as any) })
Expand Down
154 changes: 90 additions & 64 deletions frontend/src/pages/Graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,18 @@ function GraphView() {
}
})

// Collapsible Options tray (Mind Map)
const OPTIONS_OPEN_LOCAL_KEY = 'verbweaver_graph_options_open'
const [optionsOpen, setOptionsOpen] = useState<boolean>(() => {
try {
const raw = localStorage.getItem(OPTIONS_OPEN_LOCAL_KEY)
if (raw === null) return true
return raw === 'true'
} catch {
return true
}
})

// Task columns config (to determine completed status per project)
const [taskColumns, setTaskColumns] = useState<any[]>([])
const [completedColumnId, setCompletedColumnId] = useState<string | null>(null)
Expand Down Expand Up @@ -1573,70 +1585,84 @@ function GraphView() {
</div>
</div>
</div>
{/* Left controls bar */}
<div className="absolute top-2 left-2 z-10 flex items-center gap-2 bg-background/80 border border-border rounded px-2 py-1 shadow">
<label className="inline-flex items-center gap-2 text-sm" title="Hide files inside the uploads/ directory from the Mind Map.">
<input
type="checkbox"
checked={hideUploads}
onChange={(e) => {
const v = e.target.checked
setHideUploads(v)
try { localStorage.setItem(STORAGE_KEYS.GRAPH_HIDE_UPLOADS, String(v)) } catch {}
}}
/>
<span className="inline-flex items-center gap-1">
<span>Hide uploads</span>
{hideUploads && uploadsHiddenCount > 0 && (
<span className="text-[10px] leading-none px-1 py-0.5 rounded bg-muted text-muted-foreground" title={`${uploadsHiddenCount} items hidden`}>
{uploadsHiddenCount}
</span>
)}
</span>
</label>
<label className="inline-flex items-center gap-2 text-sm" title="When enabled: completed Tasks are hidden from the Mind Map.">
<input
type="checkbox"
checked={hideCompletedTasks}
onChange={(e) => {
const v = e.target.checked
setHideCompletedTasks(v)
try { localStorage.setItem(HIDE_COMPLETED_LOCAL_KEY, String(v)) } catch {}
}}
/>
<span className="inline-flex items-center gap-1">
<span>Hide completed Tasks</span>
{hideCompletedTasks && completedHiddenCount > 0 && (
<span className="text-[10px] leading-none px-1 py-0.5 rounded bg-muted text-muted-foreground" title={`${completedHiddenCount} tasks hidden`}>
{completedHiddenCount}
</span>
)}
</span>
</label>
<label className="inline-flex items-center gap-2 text-sm" title="Show directional edges for one-way links.">
<input
type="checkbox"
checked={showOneWayLinks}
onChange={(e) => {
const v = e.target.checked
setShowOneWayLinks(v)
try { localStorage.setItem('verbweaver_graph_show_one_way_links', String(v)) } catch {}
}}
/>
Display one-way links
</label>
<label className="inline-flex items-center gap-2 text-sm" title="When enabled: dragging updates and saves positions (folders saved per project). When disabled: dragging is temporary and not saved.">
<input
type="checkbox"
checked={rigidMode}
onChange={(e) => {
const v = e.target.checked
setRigidMode(v)
try { localStorage.setItem(STORAGE_KEYS.GRAPH_RIGID_MODE, String(v)) } catch {}
}}
/>
Rigid mode
</label>
{/* Left controls: collapsible Options tray */}
<div className="absolute top-2 left-2 z-10 pointer-events-auto">
<div className="bg-background/80 border border-border rounded shadow w-64">
<button
className="w-full flex items-center justify-between px-2 py-1 text-sm"
onClick={() => setOptionsOpen(o => { const next = !o; try { localStorage.setItem(OPTIONS_OPEN_LOCAL_KEY, String(next)) } catch {}; return next })}
aria-expanded={optionsOpen}
>
<span>Options</span>
<span className="text-xs">{optionsOpen ? '▾' : '▸'}</span>
</button>
{optionsOpen && (
<div className="p-2 flex flex-col gap-2">
<label className="inline-flex items-center gap-2 text-sm" title="Hide files inside the uploads/ directory from the Mind Map.">
<input
type="checkbox"
checked={hideUploads}
onChange={(e) => {
const v = e.target.checked
setHideUploads(v)
try { localStorage.setItem(STORAGE_KEYS.GRAPH_HIDE_UPLOADS, String(v)) } catch {}
}}
/>
<span className="inline-flex items-center gap-1">
<span>Hide uploads</span>
{hideUploads && uploadsHiddenCount > 0 && (
<span className="text-[10px] leading-none px-1 py-0.5 rounded bg-muted text-muted-foreground" title={`${uploadsHiddenCount} items hidden`}>
{uploadsHiddenCount}
</span>
)}
</span>
</label>
<label className="inline-flex items-center gap-2 text-sm" title="When enabled: completed Tasks are hidden from the Mind Map.">
<input
type="checkbox"
checked={hideCompletedTasks}
onChange={(e) => {
const v = e.target.checked
setHideCompletedTasks(v)
try { localStorage.setItem(HIDE_COMPLETED_LOCAL_KEY, String(v)) } catch {}
}}
/>
<span className="inline-flex items-center gap-1">
<span>Hide completed Tasks</span>
{hideCompletedTasks && completedHiddenCount > 0 && (
<span className="text-[10px] leading-none px-1 py-0.5 rounded bg-muted text-muted-foreground" title={`${completedHiddenCount} tasks hidden`}>
{completedHiddenCount}
</span>
)}
</span>
</label>
<label className="inline-flex items-center gap-2 text-sm" title="Show directional edges for one-way links.">
<input
type="checkbox"
checked={showOneWayLinks}
onChange={(e) => {
const v = e.target.checked
setShowOneWayLinks(v)
try { localStorage.setItem('verbweaver_graph_show_one_way_links', String(v)) } catch {}
}}
/>
Display one-way links
</label>
<label className="inline-flex items-center gap-2 text-sm" title="When enabled: dragging updates and saves positions (folders saved per project). When disabled: dragging is temporary and not saved.">
<input
type="checkbox"
checked={rigidMode}
onChange={(e) => {
const v = e.target.checked
setRigidMode(v)
try { localStorage.setItem(STORAGE_KEYS.GRAPH_RIGID_MODE, String(v)) } catch {}
}}
/>
Rigid mode
</label>
</div>
)}
</div>
</div>
<MiniMap
nodeColor={(node) => {
Expand Down
43 changes: 26 additions & 17 deletions frontend/src/store/nodeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,25 +115,37 @@ function resolveLinksToIds(rawLinks: unknown, nodesMap: Map<string, VerbweaverNo
const result = new Set<string>()
if (!Array.isArray(rawLinks)) return []
// Precompute indexes
const idToId = new Set<string>()
const idSet = new Set<string>()
const pathToId = new Map<string, string>()
const pathToIdLower = new Map<string, string>()
// basename (lowercased, with .md ensured) -> unique id (empty string if ambiguous)
const basenameToUniqueIdLower = new Map<string, string>()
for (const n of nodesMap.values()) {
const nid = String(n?.metadata?.id || '')
if (nid) {
idToId.add(nid)
// Only index paths that have valid IDs to avoid capturing folders without IDs
const normPath = n.path.replace(/\\/g,'/')
pathToId.set(normPath, nid)
pathToIdLower.set(normPath.toLowerCase(), nid)
if (!nid) continue
idSet.add(nid)
// Only index paths that have valid IDs to avoid capturing folders without IDs
const normPath = n.path.replace(/\\/g,'/')
pathToId.set(normPath, nid)
pathToIdLower.set(normPath.toLowerCase(), nid)
const base = (normPath.split('/')?.pop() || '')
if (base) {
const baseLower = base.toLowerCase()
const existing = basenameToUniqueIdLower.get(baseLower)
if (existing === undefined) {
basenameToUniqueIdLower.set(baseLower, nid)
} else if (existing && existing !== nid) {
// Mark ambiguous
basenameToUniqueIdLower.set(baseLower, '')
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Basename Ambiguity Detection Fails

The resolveLinksToIds function has an issue with its basename ambiguity detection. The existing && existing !== nid condition doesn't correctly re-mark a basename as ambiguous if it was previously set to an empty string. This can cause ambiguous basenames to resolve incorrectly.

Fix in Cursor Fix in Web

for (const entry of rawLinks) {
const val = String(entry || '')
if (!val) continue
// Prefer exact ID match
if (idToId.has(val)) { result.add(val); continue }
// Try as normalized path
if (idSet.has(val)) { result.add(val); continue }
// Try as normalized path (repo-relative); accept with or without .md
const norm = val.replace(/\\/g,'/')
const withMd = norm.endsWith('.md') ? norm : `${norm}.md`
const normLower = norm.toLowerCase()
Expand All @@ -143,14 +155,11 @@ function resolveLinksToIds(rawLinks: unknown, nodesMap: Map<string, VerbweaverNo
if (mdHit) { result.add(mdHit); continue }
const normHit = pathToId.get(norm) || pathToIdLower.get(normLower)
if (normHit) { result.add(normHit); continue }
// Fallback: unique suffix match (case-insensitive)
const keys = Array.from(pathToId.keys())
const keysLower = keys.map(k => k.toLowerCase())
const suffixMatches: number[] = []
keysLower.forEach((k, idx) => { if (k.endsWith(withMdLower) || k.endsWith(normLower)) suffixMatches.push(idx) })
if (suffixMatches.length === 1) {
const id = pathToId.get(keys[suffixMatches[0]])
if (id) { result.add(id); continue }
// Fallback: unique basename match (no path separators in source value)
if (!norm.includes('/')) {
const baseLower = (withMdLower.split('/')?.pop() || '')
const maybeId = basenameToUniqueIdLower.get(baseLower)
if (maybeId) { result.add(maybeId); continue }
}
}
return Array.from(result)
Expand Down