graph view

This commit is contained in:
tusharmagar 2026-01-13 12:12:52 +05:30 committed by Ramnique Singh
parent 174dcaf3ee
commit 102a9e5855
4 changed files with 766 additions and 3 deletions

View file

@ -164,3 +164,33 @@
@apply bg-background text-foreground;
}
}
.graph-view {
background-color: var(--background);
user-select: none;
}
.graph-view::before {
content: '';
position: absolute;
inset: 0;
background-image: radial-gradient(var(--border) 0.6px, transparent 0.6px);
background-size: 22px 22px;
opacity: 0.45;
pointer-events: none;
}
.graph-view > svg {
position: relative;
z-index: 1;
cursor: grab;
}
.graph-view:active > svg {
cursor: grabbing;
}
.graph-view text {
pointer-events: none;
user-select: none;
}

View file

@ -8,6 +8,7 @@ import z from 'zod';
import { Button } from './components/ui/button';
import { MessageSquare, CheckIcon, LoaderIcon } from 'lucide-react';
import { MarkdownEditor } from './components/markdown-editor';
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
import { useDebounce } from './hooks/use-debounce';
import { SidebarIcon } from '@/components/sidebar-icon';
import { SidebarContentPanel } from '@/components/sidebar-content';
@ -115,6 +116,20 @@ const toToolState = (status: ToolCall['status']): ToolState => {
}
const DEFAULT_SIDEBAR_WIDTH = 256
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
const graphPalette = [
{ hue: 210, sat: 72, light: 52 },
{ hue: 28, sat: 78, light: 52 },
{ hue: 120, sat: 62, light: 48 },
{ hue: 170, sat: 66, light: 46 },
{ hue: 280, sat: 70, light: 56 },
{ hue: 330, sat: 68, light: 54 },
{ hue: 55, sat: 80, light: 52 },
{ hue: 0, sat: 72, light: 52 },
]
const clampNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value))
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
if (!usage) return null
@ -237,6 +252,13 @@ function App() {
const [tree, setTree] = useState<TreeNode[]>([])
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
const [isGraphOpen, setIsGraphOpen] = useState(false)
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
nodes: [],
edges: [],
})
const [graphStatus, setGraphStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
const [graphError, setGraphError] = useState<string | null>(null)
// Auto-save state
const [isSaving, setIsSaving] = useState(false)
@ -544,6 +566,7 @@ function App() {
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') {
setSelectedPath(path)
setIsGraphOpen(false)
return
}
@ -560,6 +583,7 @@ function App() {
const handleSectionChange = useCallback((section: ActiveSection) => {
if (section === 'ask-ai' || section === 'agents') {
setSelectedPath(null)
setIsGraphOpen(false)
}
}, [])
@ -568,6 +592,13 @@ function App() {
const files = collectFilePaths(tree).filter((path) => path.endsWith('.md'))
return Array.from(new Set(files.map(stripKnowledgePrefix)))
}, [tree])
const knowledgeFilePaths = React.useMemo(() => (
knowledgeFiles.reduce<string[]>((acc, filePath) => {
const resolved = toKnowledgePath(filePath)
if (resolved) acc.push(resolved)
return acc
}, [])
), [knowledgeFiles])
// Get workspace root for full paths
const [workspaceRoot, setWorkspaceRoot] = useState<string>('')
@ -587,6 +618,7 @@ function App() {
data: `# New Note\n\n`,
opts: { encoding: 'utf8' }
})
setIsGraphOpen(false)
setSelectedPath(fullPath)
} catch (err) {
console.error('Failed to create note:', err)
@ -604,6 +636,10 @@ function App() {
throw err
}
},
openGraph: () => {
setSelectedPath(null)
setIsGraphOpen(true)
},
expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))),
collapseAll: () => setExpandedPaths(new Set()),
rename: async (oldPath: string, newName: string, isDir: boolean) => {
@ -671,6 +707,119 @@ function App() {
onCreate: (path: string) => ensureWikiFile(path),
}), [knowledgeFiles, recentWikiFiles, openWikiLink, ensureWikiFile])
useEffect(() => {
if (!isGraphOpen) return
let cancelled = false
const buildGraph = async () => {
setGraphStatus('loading')
setGraphError(null)
if (knowledgeFilePaths.length === 0) {
setGraphData({ nodes: [], edges: [] })
setGraphStatus('ready')
return
}
const nodeSet = new Set(knowledgeFilePaths)
const edges: GraphEdge[] = []
const edgeKeys = new Set<string>()
const contents = await Promise.all(
knowledgeFilePaths.map(async (path) => {
try {
const result = await window.ipc.invoke('workspace:readFile', { path })
return { path, data: result.data as string }
} catch (err) {
console.error('Failed to read file for graph:', path, err)
return { path, data: '' }
}
})
)
for (const { path, data } of contents) {
for (const match of data.matchAll(wikiLinkRegex)) {
const rawTarget = match[1]?.trim() ?? ''
const targetPath = toKnowledgePath(rawTarget)
if (!targetPath || targetPath === path) continue
if (!nodeSet.has(targetPath)) continue
const edgeKey = path < targetPath ? `${path}|${targetPath}` : `${targetPath}|${path}`
if (edgeKeys.has(edgeKey)) continue
edgeKeys.add(edgeKey)
edges.push({ source: path, target: targetPath })
}
}
const degreeMap = new Map<string, number>()
edges.forEach((edge) => {
degreeMap.set(edge.source, (degreeMap.get(edge.source) ?? 0) + 1)
degreeMap.set(edge.target, (degreeMap.get(edge.target) ?? 0) + 1)
})
const groupIndexMap = new Map<string, number>()
const getGroupIndex = (group: string) => {
const existing = groupIndexMap.get(group)
if (existing !== undefined) return existing
const nextIndex = groupIndexMap.size
groupIndexMap.set(group, nextIndex)
return nextIndex
}
const getNodeGroup = (path: string) => {
const normalized = stripKnowledgePrefix(path)
const parts = normalized.split('/').filter(Boolean)
if (parts.length <= 1) {
return { group: 'root', depth: 0 }
}
return {
group: parts[0],
depth: Math.max(0, parts.length - 2),
}
}
const getNodeColors = (groupIndex: number, depth: number) => {
const base = graphPalette[groupIndex % graphPalette.length]
const light = clampNumber(base.light + depth * 6, 36, 72)
const strokeLight = clampNumber(light - 12, 28, 60)
return {
fill: `hsl(${base.hue} ${base.sat}% ${light}%)`,
stroke: `hsl(${base.hue} ${Math.min(80, base.sat + 8)}% ${strokeLight}%)`,
}
}
const nodes = knowledgeFilePaths.map((path) => {
const degree = degreeMap.get(path) ?? 0
const radius = 6 + Math.min(18, degree * 2)
const { group, depth } = getNodeGroup(path)
const groupIndex = getGroupIndex(group)
const colors = getNodeColors(groupIndex, depth)
return {
id: path,
label: wikiLabel(path) || path,
degree,
radius,
group,
color: colors.fill,
stroke: colors.stroke,
}
})
if (!cancelled) {
setGraphData({ nodes, edges })
setGraphStatus('ready')
}
}
buildGraph().catch((err) => {
if (cancelled) return
console.error('Failed to build graph:', err)
setGraphStatus('error')
setGraphError(err instanceof Error ? err.message : 'Failed to build graph')
})
return () => {
cancelled = true
}
}, [isGraphOpen, knowledgeFilePaths])
const renderConversationItem = (item: ConversationItem) => {
if (isChatMessage(item)) {
return (
@ -757,6 +906,7 @@ function App() {
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
const submitStatus: ChatStatus = isProcessing ? 'streaming' : 'ready'
const canSubmit = Boolean(message.trim()) && !isProcessing
const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat')
return (
<TooltipProvider delayDuration={0}>
@ -789,7 +939,7 @@ function App() {
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="h-4" />
<span className="text-sm font-medium text-muted-foreground">
{selectedPath ? selectedPath : 'Chat'}
{headerTitle}
</span>
{selectedPath && (
<>
@ -818,9 +968,32 @@ function App() {
</Button>
</>
)}
{!selectedPath && isGraphOpen && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsGraphOpen(false)}
className="ml-auto text-foreground"
>
Close Graph
</Button>
)}
</header>
{selectedPath ? (
{isGraphOpen ? (
<div className="flex-1 min-h-0">
<GraphView
nodes={graphData.nodes}
edges={graphData.edges}
isLoading={graphStatus === 'loading'}
error={graphStatus === 'error' ? (graphError ?? 'Failed to build graph') : null}
onSelectNode={(path) => {
setIsGraphOpen(false)
setSelectedPath(path)
}}
/>
</div>
) : selectedPath ? (
selectedPath.endsWith('.md') ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<MarkdownEditor

View file

@ -0,0 +1,559 @@
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
export type GraphNode = {
id: string
label: string
degree: number
radius: number
group: string
color: string
stroke: string
}
export type GraphEdge = {
source: string
target: string
}
type GraphViewProps = {
nodes: GraphNode[]
edges: GraphEdge[]
isLoading?: boolean
error?: string | null
onSelectNode?: (id: string) => void
}
type NodePosition = {
x: number
y: number
vx: number
vy: number
}
const SIMULATION_STEPS = 240
const SPRING_LENGTH = 80
const SPRING_STRENGTH = 0.0038
const REPULSION = 5800
const DAMPING = 0.83
const MIN_DISTANCE = 34
const CLUSTER_STRENGTH = 0.0018
const CLUSTER_RADIUS_MIN = 120
const CLUSTER_RADIUS_MAX = 240
const CLUSTER_RADIUS_STEP = 45
const FLOAT_BASE = 3.5
const FLOAT_VARIANCE = 2
const FLOAT_SPEED_BASE = 0.0006
const FLOAT_SPEED_VARIANCE = 0.00025
export function GraphView({ nodes, edges, isLoading, error, onSelectNode }: GraphViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const positionsRef = useRef<Map<string, NodePosition>>(new Map())
const motionSeedsRef = useRef<Map<string, { phase: number; amplitude: number; speed: number }>>(new Map())
const motionTimeRef = useRef(0)
const draggingRef = useRef<{
id: string
offsetX: number
offsetY: number
moved: boolean
} | null>(null)
const panningRef = useRef<{
startX: number
startY: number
originX: number
originY: number
} | null>(null)
const hasCenteredRef = useRef(false)
const [viewport, setViewport] = useState({ width: 1, height: 1 })
const [pan, setPan] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null)
const [, forceRender] = useState(0)
const edgeList = useMemo(
() => edges.filter((edge) => edge.source !== edge.target),
[edges]
)
const nodeGroupMap = useMemo(() => {
const map = new Map<string, string>()
nodes.forEach((node) => map.set(node.id, node.group || 'root'))
return map
}, [nodes])
const legendItems = useMemo(() => {
const grouped = new Map<string, { label: string; color: string; stroke: string }>()
nodes.forEach((node) => {
const group = node.group || 'root'
if (grouped.has(group)) return
grouped.set(group, {
label: group === 'root' ? 'knowledge' : group,
color: node.color,
stroke: node.stroke,
})
})
return Array.from(grouped.values()).sort((a, b) => a.label.localeCompare(b.label))
}, [nodes])
const groupCenters = useMemo(() => {
const groups = Array.from(new Set(nodes.map((node) => node.group || 'root')))
if (groups.length === 0) return new Map<string, { x: number; y: number }>()
const radius = Math.min(
CLUSTER_RADIUS_MAX,
Math.max(CLUSTER_RADIUS_MIN, groups.length * CLUSTER_RADIUS_STEP)
)
const centers = new Map<string, { x: number; y: number }>()
groups.forEach((group, index) => {
const angle = (index / groups.length) * Math.PI * 2
centers.set(group, {
x: radius * Math.cos(angle),
y: radius * Math.sin(angle),
})
})
return centers
}, [nodes])
const getMotionSeed = useCallback((id: string) => {
const existing = motionSeedsRef.current.get(id)
if (existing) return existing
let hash = 0
for (let i = 0; i < id.length; i += 1) {
hash = (hash << 5) - hash + id.charCodeAt(i)
hash |= 0
}
const normalized = Math.abs(hash)
const phase = ((normalized % 360) * Math.PI) / 180
const amplitude = FLOAT_BASE + (normalized % 7) * (FLOAT_VARIANCE / 6)
const speed = FLOAT_SPEED_BASE + (normalized % 5) * FLOAT_SPEED_VARIANCE
const seed = { phase, amplitude, speed }
motionSeedsRef.current.set(id, seed)
return seed
}, [])
const getDisplayPosition = useCallback((id: string, base: NodePosition, skipMotion: boolean) => {
if (skipMotion) {
return { x: base.x, y: base.y }
}
const seed = getMotionSeed(id)
const phase = seed.phase + motionTimeRef.current * seed.speed
return {
x: base.x + Math.sin(phase) * seed.amplitude,
y: base.y + Math.cos(phase * 0.9) * seed.amplitude,
}
}, [getMotionSeed])
const getGraphPoint = useCallback((event: React.PointerEvent) => {
const container = containerRef.current
if (!container) return { x: 0, y: 0 }
const rect = container.getBoundingClientRect()
return {
x: (event.clientX - rect.left - pan.x) / zoom,
y: (event.clientY - rect.top - pan.y) / zoom,
}
}, [pan.x, pan.y, zoom])
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
const { width, height } = entry.contentRect
setViewport({ width, height })
if (!hasCenteredRef.current) {
setPan({ x: width / 2, y: height / 2 })
hasCenteredRef.current = true
}
})
observer.observe(container)
return () => observer.disconnect()
}, [])
useEffect(() => {
if (nodes.length === 0) {
positionsRef.current = new Map()
return
}
const nextPositions = new Map<string, NodePosition>()
const count = nodes.length
const radius = Math.max(110, Math.min(220, count * 9))
nodes.forEach((node, index) => {
const existing = positionsRef.current.get(node.id)
if (existing) {
nextPositions.set(node.id, { ...existing })
return
}
const angle = (index / count) * Math.PI * 2
nextPositions.set(node.id, {
x: radius * Math.cos(angle),
y: radius * Math.sin(angle),
vx: 0,
vy: 0,
})
})
positionsRef.current = nextPositions
let step = 0
let rafId = 0
let active = true
const simulate = () => {
if (!active) return
step += 1
const positions = positionsRef.current
const ids = nodes.map((node) => node.id)
const forces = new Map<string, { x: number; y: number }>()
ids.forEach((id) => forces.set(id, { x: 0, y: 0 }))
for (let i = 0; i < ids.length; i += 1) {
const idA = ids[i]
const posA = positions.get(idA)
if (!posA) continue
for (let j = i + 1; j < ids.length; j += 1) {
const idB = ids[j]
const posB = positions.get(idB)
if (!posB) continue
const dx = posB.x - posA.x
const dy = posB.y - posA.y
const distance = Math.max(MIN_DISTANCE, Math.hypot(dx, dy))
const force = REPULSION / (distance * distance)
const fx = (force * dx) / distance
const fy = (force * dy) / distance
const forceA = forces.get(idA)
const forceB = forces.get(idB)
if (forceA) {
forceA.x -= fx
forceA.y -= fy
}
if (forceB) {
forceB.x += fx
forceB.y += fy
}
}
}
edgeList.forEach((edge) => {
const posA = positions.get(edge.source)
const posB = positions.get(edge.target)
if (!posA || !posB) return
const dx = posB.x - posA.x
const dy = posB.y - posA.y
const distance = Math.max(20, Math.hypot(dx, dy))
const delta = distance - SPRING_LENGTH
const force = delta * SPRING_STRENGTH
const fx = (force * dx) / distance
const fy = (force * dy) / distance
const forceA = forces.get(edge.source)
const forceB = forces.get(edge.target)
if (forceA) {
forceA.x += fx
forceA.y += fy
}
if (forceB) {
forceB.x -= fx
forceB.y -= fy
}
})
ids.forEach((id) => {
const pos = positions.get(id)
const force = forces.get(id)
if (!pos || !force) return
const group = nodeGroupMap.get(id) ?? 'root'
const center = groupCenters.get(group)
if (!center) return
const dx = center.x - pos.x
const dy = center.y - pos.y
force.x += dx * CLUSTER_STRENGTH
force.y += dy * CLUSTER_STRENGTH
})
ids.forEach((id) => {
const pos = positions.get(id)
const force = forces.get(id)
if (!pos || !force) return
if (draggingRef.current?.id === id) {
pos.vx = 0
pos.vy = 0
return
}
pos.vx = (pos.vx + force.x) * DAMPING
pos.vy = (pos.vy + force.y) * DAMPING
pos.x += pos.vx
pos.y += pos.vy
})
forceRender((prev) => prev + 1)
if (step < SIMULATION_STEPS) {
rafId = requestAnimationFrame(simulate)
}
}
rafId = requestAnimationFrame(simulate)
return () => {
active = false
if (rafId) cancelAnimationFrame(rafId)
}
}, [nodes, edgeList, groupCenters, nodeGroupMap])
useEffect(() => {
if (nodes.length === 0) return
let rafId = 0
let lastTime = performance.now()
const animate = (time: number) => {
const delta = time - lastTime
if (delta >= 32) {
motionTimeRef.current += delta
lastTime = time
forceRender((prev) => prev + 1)
}
rafId = requestAnimationFrame(animate)
}
rafId = requestAnimationFrame(animate)
return () => {
if (rafId) cancelAnimationFrame(rafId)
}
}, [nodes.length])
const handlePointerDown = (event: React.PointerEvent) => {
if (event.button !== 0) return
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
panningRef.current = {
startX: event.clientX,
startY: event.clientY,
originX: pan.x,
originY: pan.y,
}
}
const handlePointerMove = (event: React.PointerEvent) => {
const dragging = draggingRef.current
if (dragging) {
const point = getGraphPoint(event)
const pos = positionsRef.current.get(dragging.id)
if (pos) {
pos.x = point.x - dragging.offsetX
pos.y = point.y - dragging.offsetY
dragging.moved = true
forceRender((prev) => prev + 1)
}
return
}
const panning = panningRef.current
if (panning) {
setPan({
x: panning.originX + (event.clientX - panning.startX),
y: panning.originY + (event.clientY - panning.startY),
})
}
}
const handlePointerUp = () => {
const dragging = draggingRef.current
if (dragging) {
if (!dragging.moved) {
onSelectNode?.(dragging.id)
}
draggingRef.current = null
}
panningRef.current = null
}
const handleWheel = (event: React.WheelEvent) => {
event.preventDefault()
const rawDelta = event.deltaY
const normalizedDelta = event.deltaMode === 1
? rawDelta * 16
: event.deltaMode === 2
? rawDelta * viewport.height
: rawDelta
const sensitivity = Math.abs(normalizedDelta) < 40 ? 0.004 : 0.0022
const zoomFactor = Math.exp(-normalizedDelta * sensitivity)
const nextZoom = Math.min(2.5, Math.max(0.4, zoom * zoomFactor))
if (nextZoom === zoom) return
const container = containerRef.current
if (!container) {
setZoom(nextZoom)
return
}
const rect = container.getBoundingClientRect()
const cursorX = event.clientX - rect.left
const cursorY = event.clientY - rect.top
const graphX = (cursorX - pan.x) / zoom
const graphY = (cursorY - pan.y) / zoom
setZoom(nextZoom)
setPan({
x: cursorX - graphX * nextZoom,
y: cursorY - graphY * nextZoom,
})
}
const startDragNode = (event: React.PointerEvent, nodeId: string) => {
event.stopPropagation()
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
const point = getGraphPoint(event)
const pos = positionsRef.current.get(nodeId)
if (!pos) return
const displayPos = getDisplayPosition(nodeId, pos, false)
draggingRef.current = {
id: nodeId,
offsetX: point.x - displayPos.x,
offsetY: point.y - displayPos.y,
moved: false,
}
}
const displayPositions = new Map<string, { x: number; y: number }>()
nodes.forEach((node) => {
const pos = positionsRef.current.get(node.id)
if (!pos) return
const isDragging = draggingRef.current?.id === node.id
displayPositions.set(node.id, getDisplayPosition(node.id, pos, isDragging))
})
const activeNodeId = hoveredNodeId ?? draggingRef.current?.id ?? null
const connectedNodes = useMemo(() => {
if (!activeNodeId) return null
const set = new Set([activeNodeId])
edgeList.forEach((edge) => {
if (edge.source === activeNodeId) set.add(edge.target)
if (edge.target === activeNodeId) set.add(edge.source)
})
return set
}, [activeNodeId, edgeList])
return (
<div ref={containerRef} className="graph-view relative h-full w-full">
{isLoading ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/70 backdrop-blur-sm">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Building graph</span>
</div>
</div>
) : null}
{error ? (
<div className="absolute inset-0 z-10 flex items-center justify-center text-sm text-destructive">
{error}
</div>
) : null}
{!isLoading && !error && nodes.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
No notes found.
</div>
) : null}
{legendItems.length > 0 ? (
<div
className="absolute right-3 top-3 z-20 rounded-md border border-border/80 bg-background/90 px-3 py-2 text-xs text-foreground shadow-sm backdrop-blur"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="mb-2 text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">
Folders
</div>
<div className="grid gap-1.5">
{legendItems.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span
className="inline-flex h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: item.color, boxShadow: `0 0 0 1px ${item.stroke}` }}
/>
<span className="truncate">{item.label}</span>
</div>
))}
</div>
</div>
) : null}
<svg
className="h-full w-full touch-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={() => {
handlePointerUp()
setHoveredNodeId(null)
}}
onWheel={handleWheel}
>
<rect width={viewport.width} height={viewport.height} fill="transparent" />
<g transform={`translate(${pan.x} ${pan.y}) scale(${zoom})`}>
{edgeList.map((edge, index) => {
const source = displayPositions.get(edge.source)
const target = displayPositions.get(edge.target)
if (!source || !target) return null
const isActiveEdge = activeNodeId
? edge.source === activeNodeId || edge.target === activeNodeId
: false
const strokeOpacity = activeNodeId ? (isActiveEdge ? 0.9 : 0.08) : 0.38
const strokeWidth = activeNodeId ? (isActiveEdge ? 1.6 : 0.7) : 1
const stroke = 'var(--foreground)'
return (
<line
key={`${edge.source}-${edge.target}-${index}`}
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke={stroke}
strokeOpacity={strokeOpacity}
strokeWidth={strokeWidth}
/>
)
})}
{nodes.map((node) => {
const pos = displayPositions.get(node.id)
if (!pos) return null
const isConnected = connectedNodes ? connectedNodes.has(node.id) : true
const isPrimary = activeNodeId === node.id
const nodeOpacity = activeNodeId ? (isConnected ? 1 : 0.3) : 1
return (
<g
key={node.id}
transform={`translate(${pos.x} ${pos.y})`}
className="cursor-pointer"
onPointerEnter={() => setHoveredNodeId(node.id)}
onPointerLeave={() => setHoveredNodeId(null)}
onPointerDown={(event) => startDragNode(event, node.id)}
opacity={nodeOpacity}
>
<circle
r={node.radius}
fill={node.color}
stroke={node.stroke}
strokeWidth={isPrimary ? 1.8 : 1.2}
/>
<text
x={node.radius + 6}
y={4}
className="text-xs"
style={{
fill: node.stroke,
paintOrder: 'stroke',
stroke: 'var(--background)',
strokeWidth: 3,
}}
>
{node.label}
</text>
</g>
)
})}
</g>
</svg>
</div>
)
}

View file

@ -75,6 +75,7 @@ interface TreeNode {
type KnowledgeActions = {
createNote: (parentPath?: string) => void
createFolder: (parentPath?: string) => void
openGraph: () => void
expandAll: () => void
collapseAll: () => void
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
@ -256,7 +257,7 @@ function KnowledgeSection({
const quickActions = [
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
{ icon: Network, label: "Graph View", action: () => {} },
{ icon: Network, label: "Graph View", action: () => actions.openGraph() },
{ icon: ArrowDownAZ, label: "Sort", action: () => {} },
]