-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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
|
The module does three things, in order:
-
Vite worker imports. Monaco must load from
node_modules(this is a desktop app, no CDN), and Vite bundles the workers through?workerimports. The renderer CSP allowsworker-src 'self' blob:for exactly this reason (monaco.tsheader comment). -
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; thedefaultbranch returns the editor worker for unknown labels. -
loader.config({ monaco })so@monaco-editor/reactuses 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.
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 }).
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
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
markLinksDirtydebounce 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).
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.
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→ clearkind/content/saved, showresult.error.message. -
kind === 'image'→ setkind = 'image', clear the text buffers. - otherwise → set
kind, and seed bothcontentandsavedfromresult.contentso 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 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.
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
Reading the diagram:
-
useCanvas()is the single mutation seam. Every node in the family goes throughupdateNodeData/commit/closeNode; none of them touch persistence or the undo stack directly. The boolean third argument toupdateNodeDatais what decides whether a change records history. (TheuseCanvasimplementation and the store itself are covered by Workspace, Project & Tab State.) -
monaco.tshas two consumers with different needs.EditorNodeimports it twice on purpose:import '../monaco'for the side effect (installingMonacoEnvironmentand configuring the loader) andimport { monaco, detectLanguage }for the Ctrl/Cmd+S keybinding constants.DiffNodeonly needsdetectLanguage— the worker setup arrives transitively as a module side effect. -
Only
EditorNodeandDiffNodetalk to the file/diff IPC surface. Sticky and group nodes are pure local state plus an optionallinks.markDirtynudge; they never read disk. -
LinkHandlesis 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).
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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance