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
36 changes: 36 additions & 0 deletions src/renderer/src/components/QuickSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export function QuickSearch(): React.ReactElement | null {
const selectEdge = useDiagramStore((s) => s.selectEdge)
const setSelectedNodeIds = useDiagramStore((s) => s.setSelectedNodeIds)
const presentationActive = useDiagramStore((s) => s.presentationActive)
const pendingBodyNodeIds = useDiagramStore((s) => s.pendingBodyNodeIds)
const scanDescriptions = useDiagramStore((s) => s.scanDescriptions)
const [descScanning, setDescScanning] = useState(false)

const [q, setQ] = useState('')
const [hover, setHover] = useState(0)
Expand Down Expand Up @@ -92,6 +95,17 @@ export function QuickSearch(): React.ReactElement | null {
}
}, [aiSettings.active])

// Descriptions on unopened nodes of a lazily-loaded doc aren't in memory
// yet, so `results` below can't see them. This is an explicit opt-in scan
// over just those nodes; matches get merged into c4Nodes by the action, so
// they flow back into `results` automatically once it resolves.
const handleScanDescriptions = useCallback(() => {
const query = q.trim()
if (!query) return
setDescScanning(true)
scanDescriptions(query).finally(() => setDescScanning(false))
}, [q, scanDescriptions])

const runAI = useCallback(async (text: string): Promise<void> => {
const prompt = text.trim()
if (!prompt || aiBusy) return
Expand Down Expand Up @@ -703,6 +717,28 @@ export function QuickSearch(): React.ReactElement | null {
</div>
))}

{/* Explicit opt-in scan of not-yet-opened node descriptions (lazy md-folder docs) */}
{!aiMode && q.trim() !== '' && Object.keys(pendingBodyNodeIds).length > 0 && (
<div
className="quick-search-item"
onMouseDown={(e) => e.preventDefault()}
onClick={handleScanDescriptions}
role="button"
tabIndex={-1}
aria-disabled={descScanning}
title={`Also search the ${Object.keys(pendingBodyNodeIds).length} node description(s) not yet opened`}
>
<span className="quick-search-kind">…</span>
<span className="quick-search-item-main">
<span className="quick-search-item-label">
{descScanning
? 'Searching descriptions…'
: `Search ${Object.keys(pendingBodyNodeIds).length} unopened description(s)`}
</span>
</span>
</div>
)}

{/* Ask AI action — visible while typing in search mode (only when configured) */}
{!aiMode && aiConfigured && q.trim() !== '' && !aiBusy && (
<div
Expand Down
53 changes: 53 additions & 0 deletions src/renderer/src/components/RightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1638,10 +1638,19 @@ function PropertiesContent({ readOnly = false }: { readOnly?: boolean }) {
const selectNode = useDiagramStore((s) => s.selectNode)
const selectEdge = useDiagramStore((s) => s.selectEdge)
const hubTemplates = useDiagramStore((s) => s.hubTemplates)
const pendingBodyNodeIds = useDiagramStore((s) => s.pendingBodyNodeIds)
const hydrateNode = useDiagramStore((s) => s.hydrateNode)

// Lazily-loaded md-folder docs don't have the description in memory until
// the node is opened — fetch it now.
useEffect(() => {
if (selectedNodeId) hydrateNode(selectedNodeId)
}, [selectedNodeId, hydrateNode])

// ── Node ──────────────────────────────────────────────────────────────────
if (selectedNodeId && c4Nodes[selectedNodeId]) {
const node = c4Nodes[selectedNodeId]
const descriptionPending = !!pendingBodyNodeIds[node.id]

// Generic field renderer — handles built-in typed fields and arbitrary
// extra properties stored on the node by the metamodel property definitions.
Expand Down Expand Up @@ -1672,6 +1681,14 @@ function PropertiesContent({ readOnly = false }: { readOnly?: boolean }) {
)
}
if (type === 'textarea') {
if (key === 'description' && descriptionPending) {
return (
<div className="props-field" key={key}>
<label className="props-label">{label}</label>
<AutoResizeTextarea value="Loading…" onChange={() => {}} readOnly />
</div>
)
}
return (
<div className="props-field" key={key}>
<label className="props-label">{label}</label>
Expand Down Expand Up @@ -1732,6 +1749,14 @@ function PropertiesContent({ readOnly = false }: { readOnly?: boolean }) {
)
}
if (p.type === 'textarea') {
if (p.key === 'description' && descriptionPending) {
return (
<div className="props-field" key={p.key}>
<label className="props-label">{p.label}</label>
<AutoResizeTextarea value="Loading…" onChange={() => {}} readOnly />
</div>
)
}
return (
<div className="props-field" key={p.key}>
<label className="props-label">{p.label}</label>
Expand Down Expand Up @@ -1994,13 +2019,30 @@ export function RightPanel({ readOnly = false, collapsed = false, onToggleCollap
const nodesCount = useDiagramStore((s) => Object.keys(s.c4Nodes).length)
const relationsCount = useDiagramStore((s) => Object.keys(s.c4Relations).length)
const sequencesCount = useDiagramStore((s) => Object.keys(s.sequences).length)
const pendingBodyNodeIds = useDiagramStore((s) => s.pendingBodyNodeIds)
const scanDescriptions = useDiagramStore((s) => s.scanDescriptions)
const [openSection, setOpenSection] = useState<'nodes' | 'relations' | 'sequences'>('nodes')

// ── Nodes tree filter: free-text search + type chips ──────────────────────
const [nodeSearch, setNodeSearch] = useState('')
const [nodeTypeFilter, setNodeTypeFilter] = useState<Set<C4ElementType>>(new Set())
const nodeFilterActive = nodeSearch.trim() !== '' || nodeTypeFilter.size > 0

// Descriptions on unopened nodes of a lazily-loaded doc aren't in memory
// yet, so the instant search below can't see them. "Search descriptions"
// is an explicit opt-in scan over just those nodes (see scanDescriptions).
// Matches get merged into c4Nodes by that action, so they flow back into
// the synchronous search below automatically once it resolves — no local
// result-merging needed here.
const pendingCount = Object.keys(pendingBodyNodeIds).length
const [descScanning, setDescScanning] = useState(false)
const handleScanDescriptions = () => {
const q = nodeSearch.trim()
if (!q) return
setDescScanning(true)
scanDescriptions(q).finally(() => setDescScanning(false))
}

const { nodesMatchedSet, nodesVisibleSet, nodeTypeCounts } = useMemo(() => {
const counts = new Map<C4ElementType, number>()
for (const n of Object.values(allNodes)) counts.set(n.type, (counts.get(n.type) ?? 0) + 1)
Expand Down Expand Up @@ -2088,6 +2130,17 @@ export function RightPanel({ readOnly = false, collapsed = false, onToggleCollap
onClearFilter={handleClearNodeFilter}
/>
)}
{nodeFilterActive && pendingCount > 0 && (
<button
className="lp-icon-btn"
style={{ margin: '0 12px 8px', fontSize: 12, width: 'auto', padding: '4px 8px' }}
disabled={descScanning}
onClick={handleScanDescriptions}
title={`Also search the ${pendingCount} node description(s) not yet opened`}
>
{descScanning ? 'Searching descriptions…' : `Search descriptions (${pendingCount} unopened)`}
</button>
)}
{rootNodes.length === 0
? <div className="lp-empty-state" style={{ padding: '4px 12px 8px' }}>
{nodeFilterActive ? 'No matches.' : 'No nodes.'}
Expand Down
48 changes: 43 additions & 5 deletions src/renderer/src/persist/mdFolder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,10 +296,43 @@ function writeJsonIfPresent<T>(

// ─── Deserialize: folder files → DiagramData ─────────────────────────────────

export function deserializeFromMdFolder(files: FolderFiles): DiagramData {
export interface DeserializeMdFolderOptions {
/**
* When true, node bodies (descriptions) are NOT loaded into memory —
* instead their relative file paths are returned via `bodyPaths` so a
* caller can fetch a single node's body on demand later. Everything else
* (frontmatter-derived fields, layout, sidecars) loads exactly as today.
*
* Note: `description` is only ever set on a node when its body is
* non-empty (see below) — so `description === undefined` already
* legitimately means "no description" on a normal (non-lazy) load.
* Callers must track "not yet hydrated" separately (via `bodyPaths`),
* never by checking whether `description` is falsy.
*/
lazy?: boolean
}

export interface DeserializeMdFolderResult {
data: DiagramData
/** Present only when `lazy: true` — nodeId → relative .md file path, for
* nodes whose body was not loaded. */
bodyPaths?: Record<string, string>
}

/** Extract just the body (description) text from one node's raw .md content. */
export function extractNodeBody(content: string): string {
return parseMarkdown(content).body
}

export function deserializeFromMdFolder(
files: FolderFiles,
opts?: DeserializeMdFolderOptions,
): DeserializeMdFolderResult {
const lazy = opts?.lazy ?? false
const layout = readJson<LayoutSidecar>(files[LAYOUT_FILE]) ?? { nodes: {} }

interface ParsedNode {
path: string
front: Record<string, Scalar>
body: string
/** Directory key that identifies this node as a container (or null). */
Expand All @@ -317,13 +350,15 @@ export function deserializeFromMdFolder(files: FolderFiles): DiagramData {
const isIndex = path.endsWith('/' + INDEX_BASENAME)
if (isIndex) {
const ownDirKey = dirname(path) // e.g. nodes/system-a
parsed.push({ front, body, ownDirKey, parentDirKey: dirname(ownDirKey) })
parsed.push({ path, front, body, ownDirKey, parentDirKey: dirname(ownDirKey) })
parsedByDirKey.set(ownDirKey, String(front.id))
} else {
parsed.push({ front, body, ownDirKey: null, parentDirKey: dirname(path) })
parsed.push({ path, front, body, ownDirKey: null, parentDirKey: dirname(path) })
}
}

const bodyPaths: Record<string, string> = {}

const nodes: C4Node[] = parsed.map((p) => {
const id = String(p.front.id)
const parentId = p.parentDirKey === NODES_DIR ? undefined : parsedByDirKey.get(p.parentDirKey)
Expand All @@ -339,7 +374,10 @@ export function deserializeFromMdFolder(files: FolderFiles): DiagramData {
collapsed: !!lay.collapsed,
}
if (parentId) node.parentId = parentId
if (p.body) node.description = p.body
if (p.body) {
if (lazy) bodyPaths[id] = p.path
else node.description = p.body
}
for (const [key, value] of Object.entries(p.front)) {
if (key === 'id' || key === 'type' || key === 'label') continue
node[key] = value
Expand All @@ -366,7 +404,7 @@ export function deserializeFromMdFolder(files: FolderFiles): DiagramData {
if (layout.defaultPositions) data.defaultPositions = layout.defaultPositions
if (layout.defaultViewport !== undefined) data.defaultViewport = layout.defaultViewport

return data
return lazy ? { data, bodyPaths } : { data }
}

/** True when the file map looks like a Radical md-folder (has the manifest). */
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/src/persist/webFolder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ export async function readFolderFromHandle(handle: FsDirHandle): Promise<FolderF
return out
}

/** Read a single file by its relative POSIX path (e.g. a lazily-loaded node
* body), without walking the rest of the tree. */
export async function readOneFileFromHandle(handle: FsDirHandle, relPath: string): Promise<string> {
const parts = relPath.split('/')
let dir = handle
for (let i = 0; i < parts.length - 1; i++) {
dir = await dir.getDirectoryHandle(parts[i])
}
const fileHandle = await dir.getFileHandle(parts[parts.length - 1])
const file = await fileHandle.getFile()
return file.text()
}

/** Write a file map into a directory handle, then prune our own stale managed
* files (`.md` under `nodes/` and known sidecars) that are no longer present. */
export async function writeFolderToHandle(handle: FsDirHandle, files: FolderFiles): Promise<void> {
Expand Down
67 changes: 67 additions & 0 deletions src/renderer/src/store/diagramStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,10 @@ interface DiagramStore {
defaultPositions: Record<string, NodePosition>
/** Camera state (pan + zoom) for the "All" (default) view */
defaultViewport: { x: number; y: number; zoom: number } | null
/** Node ids on a lazily-loaded md-folder doc whose `description` hasn't
* been fetched from disk yet — see `hydrateNode`. Empty for other
* document sources. */
pendingBodyNodeIds: Record<string, true>

// ── derived React Flow state ──
rfNodes: Node<C4NodeRFData>[]
Expand Down Expand Up @@ -933,6 +937,17 @@ interface DiagramStore {
// ── actions: nodes ──
addNode: (node: Omit<C4Node, 'id'>) => string
updateNode: (id: string, updates: Partial<Omit<C4Node, 'id'>>) => void
/** Fetch a lazily-loaded node's description from disk and merge it in.
* No-op if the node isn't pending. Not a user edit: doesn't push undo or
* mark a milestone dirty. */
hydrateNode: (id: string) => void
/** Explicit "search descriptions" action: scans every not-yet-hydrated
* node's body for `query` (case-insensitive substring). Matches are
* merged into the live model (so opening them next is instant);
* non-matches are read but discarded, not cached. Returns matching ids
* (only newly-discovered ones — already-hydrated matches are found by
* the normal synchronous search). */
scanDescriptions: (query: string) => Promise<Set<string>>
removeNode: (id: string) => void
toggleCollapse: (id: string) => void

Expand Down Expand Up @@ -1302,6 +1317,7 @@ export const useDiagramStore = create<DiagramStore>()(
activeViewId: null,
defaultPositions: initDefaultPositions,
defaultViewport: persisted?.defaultViewport ?? null,
pendingBodyNodeIds: {},
rfNodes: deriveRFNodes(initNodes),
rfEdges: deriveRFEdges(initNodes, initRelations),
selectedNodeId: null,
Expand Down Expand Up @@ -1543,6 +1559,48 @@ export const useDiagramStore = create<DiagramStore>()(
}
},

hydrateNode(id) {
if (!get().pendingBodyNodeIds[id]) return
const activeId = documents.getActiveId()
if (!activeId) return
documents.hydrateNodeBody(activeId, id).then((body) => {
// The node (or the whole doc) may have changed/closed while the
// read was in flight — re-check before merging.
if (!get().pendingBodyNodeIds[id]) return
set((state) => {
delete state.pendingBodyNodeIds[id]
const node = state.c4Nodes[id] as (C4Node & Record<string, unknown>) | undefined
if (node && body !== undefined) node.description = body
})
})
},

async scanDescriptions(query) {
const q = query.trim().toLowerCase()
const matches = new Set<string>()
if (!q) return matches
const activeId = documents.getActiveId()
const pendingIds = Object.keys(get().pendingBodyNodeIds)
if (!activeId || pendingIds.length === 0) return matches
const results = await Promise.all(pendingIds.map(async (id) => ({
id,
body: await documents.hydrateNodeBody(activeId, id),
})))
set((state) => {
for (const { id, body } of results) {
if (body === undefined) continue
if (!state.pendingBodyNodeIds[id]) continue // resolved another way meanwhile
if (body.toLowerCase().includes(q)) {
matches.add(id)
const node = state.c4Nodes[id] as (C4Node & Record<string, unknown>) | undefined
if (node) node.description = body
delete state.pendingBodyNodeIds[id]
}
}
})
return matches
},

removeNode(id) {
get()._pushUndo()
get()._markMilestoneEdit()
Expand Down Expand Up @@ -4157,7 +4215,16 @@ export const useDiagramStore = create<DiagramStore>()(
const defaultPos = data.defaultPositions ?? snapshotPositions(nodes)
const defaultVP: { x: number; y: number; zoom: number } | null = data.defaultViewport ?? null

// Lazily-loaded md-folder docs report which node bodies weren't read
// into `data` — track them so `hydrateNode` knows what to fetch.
const activeDocId = documents.getActiveId()
const pendingBody: Record<string, true> = {}
if (activeDocId) {
for (const id of documents.getPendingBodyNodeIds(activeDocId)) pendingBody[id] = true
}

set((state) => {
state.pendingBodyNodeIds = pendingBody as any
state.c4Nodes = nodes as any
state.c4Relations = relations as any
state.sequences = sequences as any
Expand Down
Loading
Loading