Skip to content

Sticky, Group, Editor & Diff Nodes

dazeb edited this page Sep 17, 2026 · 2 revisions

Sticky, Group, Editor & Diff Nodes

The non-terminal node family is the part of the canvas that is not a process: notes, group frames, file editors, and git diff viewers. All four live in src/renderer/src/nodes/ and share the same furniture — NodeResizer, LinkHandles, HelpBadge, nodeTitle/*NodeData from state/workspace, and the useCanvas() store bindings (updateNodeData, commit, closeNode). Two of them (EditorNode, DiffNode) additionally sit on top of a shared Monaco loading layer (src/renderer/src/monaco.ts) and a hardened markdown renderer (src/renderer/src/markdown.ts).

The family is deliberately thin: node components own only view state and IPC calls; persistence, undo bookkeeping, and layout live elsewhere.

Module map

File Role
nodes/StickyNode.tsx Colored note; header drag handle, always-editable body, collapse, color cycle
nodes/GroupNode.tsx Parent frame around other nodes; React Flow moves parentId children with it
nodes/EditorNode.tsx Monaco single-buffer editor / markdown preview / image viewer for a file path
nodes/DiffNode.tsx Read-only Monaco DiffEditor over a git path with a staged ⇄ HEAD base toggle
nodes/LinkHandles.tsx Shared link handle affordances, rendered by every node in this family
nodes/AGENTS.md Directory-level guidance for the node family (local conventions)
monaco.ts Worker routing, loader.config, detectLanguage — imported by both Monaco nodes
markdown.ts renderMarkdown() — GFM → HTML with raw HTML dropped
hooks/useSafeResize.ts rAF-deferred resize callback used instead of Monaco automaticLayout

Shared loaders

monaco.ts — worker routing is the whole point

The module does three things, in order:

  1. Vite worker imports. Monaco must load from node_modules (this is a desktop app, no CDN), and Vite bundles the workers through ?worker imports. The renderer CSP allows worker-src 'self' blob: for exactly this reason (monaco.ts header comment).
  2. self.MonacoEnvironment.getWorker. Monaco asks this for every worker it needs — the editor worker and the per-language workers (json, css/scss/less, html/handlebars/razor, typescript/javascript). The switch returns the matching worker; the default branch returns the editor worker for unknown labels.
  3. loader.config({ monaco }) so @monaco-editor/react uses the locally bundled instance rather than fetching one.

The comment at the top documents the failure mode this routing exists to prevent: returning the editor worker for a language label routes language RPCs (getFoldingRanges, hover, symbols) into a worker whose foreign module is empty, so every call rejects with Missing requestHandler or method: getFoldingRanges — fired on any editor relayout, i.e. every window or node resize.

detectLanguage(filePath) takes the extension after the last ., lowercases it, and looks it up in a fixed map (ts/tsx → typescript, md → markdown, toml/ini → ini, …), falling back to plaintext. It is extension-only: no shebang sniffing, no content inspection.

markdown.ts — preview HTML with raw HTML stripped

renderMarkdown wraps a single Marked instance configured with gfm: true, breaks: false, and a renderer override whose html() returns ''. Any raw HTML in the source document is therefore dropped rather than passed through, which is what makes dangerouslySetInnerHTML in EditorNode's preview pane acceptable. Parsing is forced synchronous ({ async: false }).

Node components

StickyNode — note with a debounced write path

sequenceDiagram
    autonumber
    participant U as User
    participant S as StickyNode
    participant C as useCanvas()
    participant M as window.termsprawl.links

    U->>S: keystroke in textarea
    S->>C: updateNodeData(id, { text }, false)
    Note over C: no history snapshot recorded
    S->>S: reset 800 ms dirty timer
    U-->>S: idle ≥ 800 ms
    S-->>M: markDirty(id)
    U->>S: blur textarea
    S->>S: 50 ms coalescing timer
    S->>C: commit()
    Note over C: one undo snapshot per edit session
Loading

Key nodes in this flow:

  • updateNodeData(id, patch, false) is the "live" write — it mutates node data but does not record history. commit() closes the undo gap afterwards.
  • The 800 ms markLinksDirty debounce exists because typing fires fast and node-link recomputation is expensive; auto-links re-run only after the typing quiesces. The call is fire-and-forget (void … .catch(() => {})).
  • The 50 ms blur timer coalesces repeated blurs; the comment states the intent plainly: one history snapshot per edit session.

Structural details: the color dot calls cycleColor, which walks STICKY_COLORS from state/workspace cyclically and writes { color } with commit = true. toggleCollapsed does the same for { collapsed }. The outer div carries sticky-node sticky-${color} plus collapsed; the header carries the drag handle and the title; the <textarea> is tagged nodrag nowheel so text selection and scrolling stay inside the text and dragging only happens via the header. NodeResizer is visible only when selected, minWidth = 120, minHeight = 80. The close button stops pointerdown propagation before calling closeNode(id).

GroupNode — parent frame

The group is a React Flow parent: the frame itself is the drag handle, and moving it moves every child (nodes carrying a matching parentId). The component keeps two local state values — editing and draft — because label editing is transient view state, not node data.

commitEdit is the interesting branch:

title = draft.trim()
if (title && title !== data.title)  updateNodeData(id, { title }, true)
else                                 commit()   // close the undo gap anyway

An empty or unchanged title is a no-op write, but still calls commit() — the comment notes this is so a blur always closes the undo gap. The practical boundary condition: a group title can never be blanked through the UI. Enter commits, Escape cancels (setEditing(false) without writing), double-click stops propagation before entering edit mode, and the input is nodrag nowheel with pointerdown stopped. NodeResizer is minWidth = 200, minHeight = 130.

The HelpBadge text is the user-facing contract for this node: closing ungroups — children stay on the canvas at the same place — and wrapping an existing selection in a new group is a right-click action on the canvas (CanvasMenu), not something this component does.

EditorNode — Monaco over the project-safe file service

State is a small explicit machine: kind: 'text' | 'markdown' | 'image' | null, plus content/saved (the dirty pair), status, and loading.

Load path. load(path, remote) calls window.termsprawl.files.read(path, remote) and branches on FileReadResult:

  • 'error' in result → clear kind/content/saved, show result.error.message.
  • kind === 'image' → set kind = 'image', clear the text buffers.
  • otherwise → set kind, and seed both content and saved from result.content so the node starts clean.

An effect re-runs this whenever data.path or data.remote changes; when data.path is cleared, all four states reset. data.remote is threaded through to the file service, so an editor node can target a remote project.

Dirty and save. dirty = kind !== 'image' && kind !== null && content !== saved — images and unloaded nodes can never be dirty. save() early-returns when there is no path, when kind is 'image', or when kind is null; otherwise it writes via window.termsprawl.files.write(data.path, content, data.remote), and on success updates saved, clears the status, and immediately calls links.markDirty(id) — a save is a content change, so no debounce is applied here.

Because save is a useCallback over content, the component routes Ctrl/Cmd+S through a ref (saveRef.current = save) so the Monaco command registered in onMount always calls the latest closure.

Rendering ladder (editor-node-body), in order: no path → "Open a file to edit"; loading with no kind and no status → "loading…"; kind === 'image' → <img src={toFilePreviewUrl(data.path)}>; showPreview (kind === 'markdown' && data.preview) → dangerouslySetInnerHTML with renderMarkdown(content); text/markdown → <Editor> with detectLanguage(data.path).

Resize. automaticLayout: false and a manual useSafeResize(hostRef, () => editorRef.current?.layout()). The comment explains why: Monaco's own automaticLayout ResizeObserver re-triggers itself on fractional sizes, producing the "ResizeObserver loop completed with undelivered notifications" warning on every node resize. Other options: minimap off, fontSize: 12, scrollBeyondLastLine: false, wordWrap: 'on', theme: 'vs-dark'. NodeResizer is minWidth = 240, minHeight = 160.

Boundary conditions worth noting. openFile() writes { path, remote: null } with commit = true, so picking a new file drops any prior remote association. The image branch builds its preview URL from data.path alone — data.remote is not forwarded to toFilePreviewUrl in this excerpt. And the source evidence for this file is truncated at line 196 (the file is larger); the closing JSX of editor-node-body and the component tail are not covered here.

The HelpBadge text doubles as the behavior spec: opens files through the project-safe file service, Ctrl+S writes utf-8, the lime dot means unsaved, markdown preview strips raw HTML, images preview but are not editable here, and the path survives a project reopen.

DiffNode — read-only git diff

DiffNode mirrors EditorNode's shape but is simpler: no writes, no dirty tracking, no formatting toggle. The original side comes from the selected ref (staged = git index, or HEAD); the modified side is always the working tree. The comment is explicit that only path and base are persisted with the node — the diff payload is fetched on demand over IPC and held in component state, never serialized.

load(path, base, remote) calls window.termsprawl.diff.info(path, base, remote). On throw it synthesizes an error result rather than leaving info null: { original: null, modified: null, error: { code: 'IO', message: String(err) } }, so the same status rendering path handles both transport failures and service-reported errors. status is info?.error?.message; original/modified default to ''.

toggleBase() flips data.base between 'staged' and 'HEAD' via updateNodeData(..., true), which re-runs the effect and refetches. openFile() behaves identically to EditorNode's. The DiffEditor is configured readOnly: true, renderSideBySide: true, minimap off, fontSize: 12, automaticLayout: false, with the same useSafeResize-driven diffEditorRef.current?.layout(). NodeResizer is minWidth = 260, minHeight = 180.

How the family fits together

flowchart TB
  subgraph nodes["src/renderer/src/nodes — non-terminal family"]
    SN["StickyNode<br/><i>note</i>"]
    GN["GroupNode<br/><i>parent frame</i>"]
    EN["EditorNode<br/><i>Monaco editor</i>"]
    DN["DiffNode<br/><i>Monaco diff</i>"]
  end

  subgraph furniture["Shared node furniture"]
    LH["LinkHandles"]
    HB["HelpBadge"]
    NR["NodeResizer<br/>@reactflow/node-resizer"]
    TT["nodeTitle / *NodeData<br/>state/workspace"]
  end

  subgraph loaders["Shared loaders & hooks"]
    MON["monaco.ts<br/>MonacoEnvironment.getWorker<br/>detectLanguage()"]
    MD["markdown.ts<br/>renderMarkdown()"]
    SR["useSafeResize()"]
  end

  CV["useCanvas()<br/>updateNodeData · commit · closeNode"]
  IPC["window.termsprawl (preload bridge)"]

  SN & GN & EN & DN --> LH
  SN & GN & EN & DN --> HB
  SN & GN & EN & DN --> NR
  SN & GN & EN & DN --> TT
  SN & GN & EN & DN --> CV

  EN & DN --> SR
  EN & DN --> MON
  EN --> MD

  EN -->|"files.read · files.write · files.openDialog"| IPC
  DN -->|"diff.info · files.openDialog"| IPC
  SN -->|"links.markDirty (800 ms debounce)"| IPC
  EN -->|"links.markDirty (immediately on save)"| IPC
Loading

Reading the diagram:

  • useCanvas() is the single mutation seam. Every node in the family goes through updateNodeData / commit / closeNode; none of them touch persistence or the undo stack directly. The boolean third argument to updateNodeData is what decides whether a change records history. (The useCanvas implementation and the store itself are covered by Workspace, Project & Tab State.)
  • monaco.ts has two consumers with different needs. EditorNode imports it twice on purpose: import '../monaco' for the side effect (installing MonacoEnvironment and configuring the loader) and import { monaco, detectLanguage } for the Ctrl/Cmd+S keybinding constants. DiffNode only needs detectLanguage — the worker setup arrives transitively as a module side effect.
  • Only EditorNode and DiffNode talk to the file/diff IPC surface. Sticky and group nodes are pure local state plus an optional links.markDirty nudge; they never read disk.
  • LinkHandles is uniform across the family. Every node renders it unconditionally, which is what keeps node-to-node linking orthogonal to node type (see Node Links, Edges & Link Inspector).

Boundary conditions and extension points

Hard limits enforced in the components. NodeResizer minimums are per-type: sticky 120×80, group 200×130, editor 240×160, diff 260×180. Everything interactive inside a node is tagged nodrag nowheel (the sticky textarea, the group label input, the Monaco hosts, the preview panes) so React Flow's pan/drag/zoom does not steal pointer and wheel events. Close buttons stop propagation on both pointerdown and click, otherwise pressing × would begin a node drag.

Undo granularity is a deliberate per-node policy. Sticky records one snapshot per edit session (typing is uncommitted, blur commits). Group records on Enter/blur and calls bare commit() even when nothing changed, so an edit session that ends in a no-op does not leave an open undo gap. Editor and Diff commit structural changes immediately (open, togglePreview, toggleBase, new path) but never commit file content — content is saved explicitly to disk instead, and only the save calls links.markDirty.

Adding a new Monaco language worker. getWorker's default branch returns the editor worker, which is correct only for labels with no language features. To support a new language with a dedicated worker you must add a ?worker import at the top of monaco.ts and a matching case in the switch. Silently extending the detectLanguage map without adding the worker case is the trap documented at the top of the file — the editor will mount, then reject every language RPC on the next relayout.

Adding a new non-terminal node type. Follow the family pattern: a component taking NodeProps<YourNodeData>, NodeResizer + LinkHandles + HelpBadge + title from nodeTitle, mutations through useCanvas(), and a HelpBadge whose text states the behavior contract. If it hosts a Monaco instance, do not enable automaticLayout — wire useSafeResize to a rAF-deferred layout() on the editor ref instead.

Markdown safety is a load-bearing default. renderMarkdown is only safe for dangerouslySetInnerHTML because the html() renderer returns the empty string. Any change there widens the injection surface of the editor preview pane.

Error surfaces are local, not global. There is no toast or modal in this family. EditorNode writes human-readable text into status; DiffNode normalizes both transport throws and service errors into a single DiffInfoResult.error shape rendered by .diff-status. Anything that needs user attention beyond the node body has to be raised elsewhere.

Sources: src/renderer/src/nodes/StickyNode.tsx, src/renderer/src/nodes/GroupNode.tsx, src/renderer/src/nodes/EditorNode.tsx, src/renderer/src/nodes/DiffNode.tsx, src/renderer/src/monaco.ts, src/renderer/src/markdown.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally