renderer-editor
Skill9 starsChanged 4 days agoMITView on GitHub
CodeMirror 6 patterns in Walph (one EditorView per editor group, one document per tab with a mirror state for split views, language compartments, dirty tracking, reveal/selection, diff view with @codemirror/merge, position conventions). Use when creating or reviewing anything in src/renderer/src/components/editor or any editor extension (LSP, gutter, settings).
In this repository17 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- app-testingSkill
- claude-panelSkill
- commands-settingsSkill
- containersSkill
- debuggerSkill
- files-git-searchSkill
- i18nSkill
- lspSkill
- main-ipcSkill
- main-processesSkill
- packaging-releaseSkill
- renderer-componentsSkill
- renderer-storeSkill
- renderer-stylesSkill
- renderer-terminalSkill
- workspacesSkill
The file
We split this skill into the rules it gives an agent
8 rules. About 0 are common, 0 are distinctive, and 8 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
8
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 71
Only the owner state has the dirty/LSP/autosave listener and publishes topLines.
- Very specific: line 74
When a group disappears with focus inside, focusContext.ts refocuses (no focusout fires).
- Very specific: line 115
Read the current text with currentState(tabId) (handles both visible and hidden tabs).
- Very specific: line 126
Every save (writeTab in saveActions.ts) first runs applySaveTransforms (saveTransformActions.ts, pure part in lib/saveTransforms.ts): trim …
- Very specific: line 191
Don't create an EditorView outside mountView(groupId, …) (one per editor group) and never put one EditorState in two views: a tab shown …
- Very specific: line 194
Don't dispatch effects or changes to a single view when the tab may be shown in several groups: use dispatchEffects, applyTabEdits, …
- Very specific: line 196
Don't use React state for document content — the source of truth is the EditorState.
- Very specific: line 197
Don't wrap the app in StrictMode (double mount breaks the imperative view).
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Editor (src/renderer/src/components/editor)
Architecture
- There is one
EditorViewper editor group (1 toMAX_EDITOR_GROUPS= 8, in a grid of splits), kept in a registry insidecomponents/editor/groupViews.ts(mountView(groupId, parent),getView(groupId?),shownTabId(groupId?),showDocument(groupId, tabId),hideDocument(groupId, tabId, keep),unmountView(groupId, keep)). Omitting the group id means the active group (setActiveGroup, fed by a store subscription), so LSP actions, search and Claude context keep usinggetView()/shownTabId()unchanged.CodeEditor.tsxmounts one view perEditorGroup. The document modules do not import the store (the store injects callbacks withbindDocumentHandlers), so store and commands can save/reload tabs without an import cycle. They are layered, each importing only the ones before it in this list:documentState.ts(shared mapsstates/savedDocs/indents/topLines/pendingViews, handlers, edit and doc listeners, compartments, current config/theme,matchesSaved,hasDocument),groupViews.ts(views, owners, mirrors,promote,forwardToTwins,showDocument/hideDocument,currentState,tabViewState,dispatchEffects),documentConfig.ts(live reconfiguration:setEditorTheme/setEditorPhrases/setEditorConfig,reconfigureAll,applyLanguage, indent, baseline, diagnostics, lightbulb gutter),documentStore.ts(extensionsFor,loadDocument/forgetDocument/saveDocument/replaceDocument/applyTabEdits). Import each symbol from its owner; there is no barrel. - One document per tab. Its canonical
EditorState(history, LSP extensions, dirty listener,lspChange) lives in the view of ONE group that shows it (the owner,ownersmap) or, hidden, in thestatesMap — this preserves document, selection and undo history.currentState(tabId)reads the owner view first, thenstates. - Same tab in several groups = every view after the first gets a mirror state (
createMirror): same doc/selection and the same language/theme/config/git baseline/diagnostics/lightbulb, butmirrorSetup(nohistory()) and no edit listener. Views sync with the CodeMirror split-view pattern: each view is created withdispatchTransactions, which forwards every transaction with changes to ALL the other views of the tab (twinsOf) annotated withsyncAnnotation(no echo: forwarded transactions are never forwarded again;userEventandaddToHistory: falseare copied). Edits from any group therefore pass through the owner's listener exactly once: onelspChangestream, one dirty update, one autosave signal. Undo/redo (Mod-z / Mod-y) in a mirror runundo(ownerView)(runOnCanonical) and then copy the selection back. When the owner stops showing the tab while mirrors still do, the first mirror is promoted (promote: it takes the canonical state, keeping its own selection and scroll) and the others stay mirrors of it; when the tab is closed, every view drops it. - Effects that must reach every copy go through
dispatchEffects(tabId, effects)/reconfigureAll(language, baseline, diagnostics, theme/config compartments, lightbulb, indent): visible views (owner and mirrors) bydispatch, hidden states bystates.set(...update...). Never touchstatesor one view directly for those. - The doc is always
\n-normalized; the file's EOL/indent are detected (lib/textFormat.ts), kept on theFileTaband re-applied on save (serializeDoc). External changes replace only the differing range (lib/textDiff.ts). - Switching tabs in a group =
showDocument/hideDocumentfromCodeEditor's effect (per group). Loading from disk is shared (ensureLoaded: two groups showing the same unloaded tab read it once). - Closed tabs are pruned (
forgetDocument) bystartTabReactionsintabActions.tsthroughonTabsClosedofstore.ts(a tab entity disappears only when no group shows it); each module prunes its own per-tab maps the same way. subscribeEdits= user edits (autosave);subscribeDocChanges= any text change of a tab, incl. disk reloads (the Markdown preview follows the buffer through it).DiffEditoris a separate component (keyed by tab id) usingMergeViewfrom@codemirror/merge, read-only. ADiffTabis a git diff (working tree / staged), a commit diff (commit), a Claude proposal (proposal) or a comparison of two arbitrary files (compare: { left, right },compareActions.ts; a side reads the open tab's buffer throughcurrentState, or the disk withsaved, and refreshes onsubscribeDocChanges/fileRevs). Only plain git diffs go to the session and follow renames (isForeignDiff);MarkdownPreviewrenders the source tab's buffer (currentState), sanitized bylib/markdownPreview.ts.
Editor groups
tabsis the list of tab ENTITIES (one per file/diff/preview);editorGroups(lib/editorGroups.ts, pure) holdtabIds+ the active tab per group, in the grid's reading order, andeditorLayoutis the split tree (shared/editorLayout.ts: leaves{ group }, nodes{ direction: 'row' | 'column', children, sizes }with fractions summing to 1). Both are written only bycommitGroupsinstore.ts(which also reconciles the tree with the groups);activeTabIdmirrors the active group's active tab. A tab entity dies (dirty prompt, LSP close) only when no group shows it.- Group operations are pure functions over
GroupsState({ groups, activeGroupId, layout }):splitGroup(side),focusGroupAt,focusGroupToward(geometry vianeighborGroup),moveTabToAdjacentGroup,joinAllGroups,resizeGroups,closeGroup,dropEmptyGroups. Functions that add or remove groups keep the tree in sync; anything else may spread the state and leavelayoutalone. EditorArearenders the groups as a FLAT keyed list absolutely positioned fromlayoutRects, plus oneResizerper boundary (layoutSashes): changing the tree never remounts a group, so itsEditorViewsurvives splits and joins.- The session stores
editorGroups+editorLayout(leaves = group indices, validated byparseLayoutin main and in the renderer); sessions from before the grid (editorSplitRatio, 2 groups) become a row with that ratio. - Only the owner state has the dirty/LSP/autosave listener and publishes
topLines. Typing events (input.type/input.complete) are NOT forwarded to the twin: they would open completion there and cancel the real request. Undo/redo (keys and the native menu'sbeforeinput) run on the owner. - When a group disappears with focus inside,
focusContext.tsrefocuses (nofocusoutfires). - The Markdown preview (
kind: 'markdownPreview') uses its own DOMPurify instance (lib/markdownPreview.ts); local images only throughfs.readImage(viafileViewActions.ts) (main: window folder, realpath + dev/ino, ≤ 10 MB). - On macOS the Edit menu is explicit (the
editMenurole's "Paste and Match Style" owns ⇧⌘V).
Symbols, breadcrumbs, outline and sticky scroll
- The symbol tree of a tab (
LspDocumentSymbol[]:range= whole declaration,selectionRange= name) comes fromcomponents/editor/documentSymbols.ts: the language server when one is ready for the file (lsp.documentSymbols, a tree), elselib/syntaxSymbols.ts(generic Lezer fallback: definition nodes by the names the@lezer/*grammars use, Markdown headings nested by level). Only tabs someone watches (watchDocumentSymbols, through theuseDocumentSymbols(tabId)hook) are computed, 350 ms after a text change (subscribeDocChanges) or a syntax tree change, and at once when a server changes state (refreshDocumentSymbols). Unchanged trees keep their reference. components/editor/viewEvents.tsis the only way for React to follow a view's selection, text or syntax tree (viewEventslives ineditorConfigExtension, so owner and mirror states both have it).useSymbolPathAtCursor(groupId, tabId, tree)snapshots the index path of the symbols around the group's cursor (lib/symbolTree.tssymbolIndexPathAt), so moving inside one symbol re-renders nothing.- Breadcrumbs (
editor/Breadcrumbs.tsx,breadcrumbs.enabled) render insideCodeEditor, one per group: path segments from the workspace folder (lib/breadcrumbs.ts; menu = the folder listing fromdirs/loadDir, folders navigate inside the same menu) then the symbol path (menu = sibling symbols).editor.focusBreadcrumbs(⌘⇧. / Ctrl+Shift+.) is abreadcrumbsFocusrequest for the active group: focuses the last segment and opens its menu; ←/→ move, Enter/Space/↓ open, Esc returns to the editor. Jumps go throughrevealDocumentSymbol(records navigation). - Sticky scroll (
editor/stickyScroll.ts,editor.stickyScroll.enabled+maxLineCount, ineditorConfigExtensionasEditorViewConfig.stickyScroll= max lines or 0): per-viewViewPlugin; scopes from the syntax tree (foldNodeProp) plusfoldable()when the language folds byfoldService(Markdown); the pure choice of lines islib/stickyScroll.ts. It providesEditorView.scrollMargins, so the cursor is never scrolled under the strip.
Rules
- Per-tab extensions go into
extensionsFor(tabId). Theme/visual extensions go intosetup.ts(editorTheme). - Anything reconfigurable at runtime (language, settings, LSP) uses a
Compartmentandview.dispatch({ effects: compartment.reconfigure(…) })— never recreate the state. - Adding an editor option: declare the setting in
shared/settings.ts, put its extension in a compartment insetup.ts(editorConfigExtension,themeCompartment), expose a setter indocumentConfig.ts(setEditorConfig/setEditorTheme) that reconfigures the visible view and the stored states of hidden tabs (reconfigureAll), and call it fromappearance.ts. New tabs get the current value fromextensionsFor(documentStore.ts). Font size/family are CSS variables (--editor-font-*) plusremeasureEditor(), not compartments. - Async work started for a tab (file load, language load) must check that the tab is still the one shown in that
group (
shownTabId(groupId) === tabId) before touching the view;applyLanguage/setDocumentBaselinealready reach every copy, so prefer them over touching a view. - Dirty = current doc differs from
savedDocs.get(tabId)(doc.eq) — undoing back to the saved text makes the tab clean. - Read the current text with
currentState(tabId)(handles both visible and hidden tabs). - Options that depend on the tab AND the settings (rulers:
editor.rulers+ EditorConfigmax_line_length) live in the per-tabtabOptionscompartment (tabOptionsExtension(tabId)indocumentState.ts, per-tab values intabRulers, settersetDocumentRulers);createMirrormust build it too. Global view options (renderWhitespace) stay ineditorConfigExtension. Rulers are alayer(rulers.ts), whitespace a viewport-only decoration plugin (renderWhitespace.ts); colors only via CSS tokens instyles/editor.css. Indentof tabs may carry its ownwidth(EditorConfigtab_width);indentExtension/formatOptionsuse it beforeeditor.tabSize.
Save pipeline and EditorConfig
- Every save (
writeTabinsaveActions.ts) first runsapplySaveTransforms(saveTransformActions.ts, pure part inlib/saveTransforms.ts): trim trailing whitespace, insert final newline, trim final newlines, from the settings with the tab's EditorConfig on top. It runs BEFOREsaveDocumentsets the baseline, so the watcher echo matches. Auto save passesauto: true(lines with a cursor are spared). "Format on save" hands its edits toapplySaveTransforms(formatTab'sapply), which composes bothChangeSets into ONEapplyTabEditscall: one undo step. The transforms are idempotent, so the second pass insidewriteTabis a no-op. - EditorConfig (
editorConfigActions.ts, parser/globs/resolution inlib/editorConfig.ts):ensureLoadedawaitsprepareTabEditorConfigbefore reading the file (it may forcelatin1viareadEncodingFor, sets the rulers), and every indent decision for a tab goes throughresolveTabIndent(load, disk reload, after save), never a bareresolveIndent. EOL and charset apply at save (saveFormatFor); an EOL the user toggled in the status bar and a user-chosen encoding win..editorconfigfiles are cached per folder and dropped when the watcher reports them.
Positions
| System | Convention |
| CodeMirror offsets | 0-based, in UTF-16 code units |
doc.line(n) | 1-based line numbers |
Reveal in the store | line 1-based, column 0-based (chars), length in chars |
| ripgrep | UTF-8 bytes (converted in main) |
| LSP | 0-based line, UTF-16 character — write a tested converter, don't inline the math |
// ✅ CORRECT — clamp before building a position
const line = doc.line(Math.min(Math.max(reveal.line, 1), doc.lines))
const from = Math.min(line.from + reveal.column, line.to)Debugger gutter
breakpointGutter(owner)goes in BOTHextensionsFor(true) andcreateMirror(false): only the canonical state carries thereporterplugin that tells the store which breakpoint lines an edit moved (debugHost().moved, out of the update cycle); a mirror reporting too would apply the same move twice. Marks map through edits until the store answers withsetBreakpointMarks.- The field seeds itself from
debugHost()increate(tab id from thelspTabIdfacet), so tabs loaded after a breakpoint change, and mirrors, are born with the current marks and stopped line; never seed them by hand.
Languages
lib/language.ts → loadLanguage(path) (lazy, @codemirror/language-data) and languageName(path) for the status bar.
Snippets and Emmet
- One snippet engine for LSP items, user snippets and Emmet:
lib/snippetSyntax.tsparses the VS Code syntax (tabstops, placeholders, choices, mirrors, variables,${VAR/regex/format/flags}),lib/snippetVariables.tsgives the variable values from a context, andcomponents/editor/snippetExpand.ts(expandSnippet(view, body, from, to)) resolves them for the tab and inserts with CodeMirror'ssnippet(). Never callsnippet()directly with server or user text. - CodeMirror fields have no choices nor nested placeholders: a choice becomes its first option and its field ranges are
kept in
snippetChoices.ts(choiceField), found by simulatingnextSnippetFieldon the inserted state; selecting the field opens the completion with the options and the other sources (LSP, user snippets, Emmet) returnnullthere (choiceAt). Tabstop transforms are plain mirrors; nested placeholders flatten into the outer one. - User snippets come from main (
src/main/snippets/:<userData>/snippets/<languageId>.json,*.code-snippetswithscope, and the view folder's.vscode/*.code-snippets; parsed as untrusted JSONC, watched, pushed assnippets:changed) into the store (snippetsslice,snippetActions.ts). Editor extensions read them throughsnippetHost(bound bysnippetActions.ts, likelspHost); the tab's VS CodelanguageIdislib/languageId.ts. - Snippet/Emmet extensions are per tab (
snippetExtensions(tabId)in both the canonical and the mirror state).editor.snippetSuggestionsis a boost (top/bottom) ornone(no source). - Emmet (
lib/emmet.tspure,components/editor/emmet.tseditor): the syntax comes from thelanguageId(+emmet.includeLanguages), the context from the syntax tree (regionAt: markup text, JSX content, a CSSBlock; never strings, comments, tags or attributes; JS code of a JSX file only where an element fits). The Tab binding isPrec.highand gives way to an open completion and to an active snippet field (whose keymap isPrec.highest).
Don'ts
- Don't create an
EditorViewoutsidemountView(groupId, …)(one per editor group) and never put oneEditorStatein two views: a tab shown twice uses the mirror state. Don't give a mirror its own history or alspChange/dirty listener — that would duplicatedidChangeand the undo stack. - Don't dispatch effects or changes to a single view when the tab may be shown in several groups: use
dispatchEffects,applyTabEdits,replaceDocument(they reach the twin). - Don't use React state for document content — the source of truth is the
EditorState. - Don't wrap the app in
StrictMode(double mount breaks the imperative view).
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

