Imported from hackerbara/changedown (
packages/vscode-extension/AGENTS.md). Install upstream withnpx skills add hackerbara/changedown --skill vscode-extension. Copyright stays with the author.
VS Code Extension — changedown-vscode
Editor integration: decorations, commands, panels (Review + Settings), git integration, DOCX editor, tracking mode, view mode cycling.
Build & Test
npm run build # Full build from root (required first)
npm run compile -w changedown-vscode # TS compile only (~2s)
npm run test:fast # Parser tests, no VS Code (<1s)
npm run test:slow # Playwright + VS Code Electron (~30s)
Package for install: cd packages/vscode-extension npx @vscode/vsce package --no-dependencies cursor --install-extension changedown-0.0.1.vsix
Debug: press F5 in VS Code (launches Extension Development Host).
Source Layout
src/
├── commands/ Command registrations (change, comment, scm, setup, test)
│ └── anchor-commands.ts AnchorCommands — inspectAnchors, repairAnchors
├── docx/ DOCX editor provider and preview
├── preview/ Markdown preview plugin
├── view/ View components
├── extension.ts Entry point — registers commands, creates controller (26K)
├── controller.ts State machine — tracking, view mode, events (119K)
├── decorator.ts Decoration engine — colors, hiding, cursor unfolding (63K)
├── review-panel.ts Review panel webview (63K)
├── settings-panel.ts Settings panel webview (51K)
├── lsp-client.ts LSP client connection
├── git-gutter-manager.ts Git gutter takeover
├── git-integration.ts Git extension API wrapper
└── hover-provider.ts Hover info for changes
Controller State Machine
The controller (controller.ts) is the central state machine. See ARCHITECTURE.md
for cross-cutting flows (L2↔L3 lifecycle, accept/reject, edit boundary).
State Fields
Tracking & View:
_trackingMode: boolean— enables CriticMarkup wrapping on edits_viewMode: ViewMode—'review'|'changes'|'settled'|'raw'_showDelimiters: boolean— controls delimiter visibility (default false)userTrackingOverrides: Map<uri, boolean>— per-document user toggle (trumps LSP)documentStates: Map<uri, {tracking, viewMode}>— LSP-sourced per-doc state
Document Shadow & IDs:
documentShadow: Map<uri, string>— previous text for deletion extractionnextScIdMap: Map<uri, number>— per-document ct-ID allocator
Edit Boundary:
pendingEditManager: PendingEditManager— wraps core EditBoundaryStateisApplyingTrackedEdit: boolean— re-entrancy guard during header/footnote writesunconfirmedTrackedEdit— deferred insertion/substitution awaiting selection confirmation (50ms)
View State:
currentView: View— currentViewobject (name, projection, display); set viaBaseController.setView()_userDisplay: Partial<DisplayOptions>— user display overrides merged atop preset defaultsconvertingUris: Set<uri>— suppresses tracking during L3→L2 conversion
Cursor:
lastCursorOffsets: Map<uri, number>— direction for hidden range snapisSnappingCursor: boolean— guard against recursive snaplastActiveEditorUri: string— prevents spurious sidebar-toggle re-fires
Cut/Paste Moves:
pendingCut: {text, timestamp, moveId}— paired with paste within 60s window
LSP Integration:
overlaySender— sends pending edit overlay for previewviewModeSender— notifies LSP of view mode changescursorPositionSender— notifies LSP of cursor moves (for CodeLens)batchEditSender— brackets batch edits to skip re-promotionlspClient— sends lifecycle requests (accept/reject/amend/supersede)
Event Handler Chain
Typical keystroke flow:
onDidChangeTextDocument
├─ Guards: undo/redo? projected view? converting? comment widget?
├─ Tracking: deletions auto-confirm, insertions queue as unconfirmed (50ms)
├─ Update documentShadow
└─ transformCachedDecorations (optimistic), scheduleDecorationUpdate (50ms)
onDidChangeTextEditorSelection (1-5ms later)
├─ Confirm pending edit if queued → handleTrackedEdits()
├─ Flush on cursor move if outside pending range
├─ updateChangeAtCursorContext → cursorPositionSender to LSP
└─ snapCursorPastHiddenRanges (direction-aware, max 10 iterations)
scheduleDecorationUpdate (50ms debounce)
└─ updateDecorations(editor) → merge LSP cache + overlay → decorator.decorate()
scheduleNotifyChanges (120ms debounce)
└─ fire onDidChangeChanges (SCM, Change Explorer, timeline)
View Mode Transitions
| BuiltinView | Projection | Buffer | Behavior |
|---|---|---|---|
review |
current |
Original markup | All decorations visible. |
simple |
current |
Original markup | Cursor-on-line reveals; off-line changes hidden. |
final |
decided |
Original markup | buildDecorationPlan hides deletions/originals; no buffer swap. |
original |
original |
Original markup | buildDecorationPlan hides insertions/modified; no buffer swap. |
All views use the same buffer (original CriticMarkup). buildDecorationPlan handles all projections
natively based on view.projection. No buffer swap, no read-only enforcement, no swap files.
Format State Ownership
Format state (L2 vs L3 per document) is SDK-owned via FormatService in BaseController.
The extension reads format from controller.formatService.getDetectedFormat() or
DocumentState.format rather than maintaining its own format tracking.
AnchorCommands
commands/anchor-commands.ts — L3 anchor inspection and repair commands:
changedown.inspectAnchors/changedown.inspectUnresolved— shows a QuickPick of all unresolved anchors (ghost nodes) in the active document. Selecting one navigates the cursor to that anchor's position.changedown.repairAnchors— triggers an L3→L2→L3 round-trip on the active document viacontroller.formatService.demoteToL2()+promoteToL3(). Forces re-anchoring of all changes. Only works when document is in L3 format.
Both commands require an active document with controller.getActiveUri().
Deprecated API
setViewMode(mode) on the controller is a deprecated facade. The new API is:
controller.setProjection(projection)— setcurrent|decided|originalcontroller.setDisplay(options)— setDisplayOptions(delimiters, filters, etc.)
VIEW_MODE_PRESETS in @changedown/core/host maps ViewMode names to their
Projection + DisplayOptions equivalents.
Debounce Timers
| Timer | Duration | Purpose |
|---|---|---|
| Decoration update | 50ms | Coalesce parse + decorate |
| Notify changes | 120ms | Batch SCM/Explorer/panel notifications |
| Overlay send | 50ms | Throttle pending overlay to LSP |
| Edit confirmation | 50ms | Auto-discard unconfirmed insertion if no selection |
| Safety-net (PEM) | 5s interval | Flush pending if elapsed > pauseThresholdMs |
Decoration System
17 TextEditorDecorationType instances:
Change types: insertionObj (green underline), deletionObj (red strikethrough), substitutionOriginalObj (red strikethrough), substitutionModifiedObj (green), highlightObj (yellow bg), commentObj (blue border + bubble emoji)
Structural: hiddenObj (CSS display:none), unfoldedObj (gray italic for cursor reveal),
activeHighlightObj (blue bg for cursor's change), decidedDimObj (50% opacity),
decidedRefObj (gray for [^cn-N] refs), ghostDeletionObj (red italic before pseudo-element)
Moves: moveFromObj (purple strikethrough + up-arrow), moveToObj (purple underline + down-arrow)
Overview ruler: 5 ruler types using ThemeColor tokens for user override
Per-author dynamic types: 5-color palette cycling (green, purple, orange, teal, blue). Deletions always use fixed red (strikethrough signals removal, author color would contradict).
hiddenObj Lifecycle
The display:none CSS cache persists in VS Code even after setDecorations(type, []).
Fix: dispose/recreate the type when transitioning from had-ranges → no-ranges.
Guard: only when changes.length > 0 (comment thread editors share the type;
disposing would break the main document).
Cursor Unfolding
In changes mode with showDelimiters=true, when cursor enters a change's contentRange,
delimiters move from hiddenObj → unfoldedObj (gray italic). Delimiters become
visible while cursor is inside the change, hidden again when cursor leaves.
Key Implementation Details
Status fallback chain (recurring bug source):
node.metadata?.status ?? node.inlineMetadata?.status ?? node.status
Always three tiers. review-panel.ts and hover-provider.ts have had bugs from
using only metadata?.status.
Re-entrancy guard: isApplyingTrackedEdit set during header writes, footnote
appends, comment handlers. Prevents tracking from wrapping system-generated edits.
Monaco NBSP: Monaco renders all spaces as U+00A0. Normalize with
.replace(/\u00a0/g, ' ') when reading DOM text.
VS Code Extension Points
Commands: changedown.toggleTracking, .acceptChange, .rejectChange,
.acceptAll, .rejectAll, .nextChange, .previousChange, .addComment, .toggleView,
.inspectAnchors, .inspectUnresolved, .repairAnchors
Configuration: changedown.trackingMode (boolean), changedown.showDelimiters (boolean),
changedown.clickToShowComments (boolean), changedown.decorationStyle ('foreground'|'background'),
changedown.authorColors ('auto'|'always'|'never'), changedown.gutterStrategy ('auto'|'assume-unchanged'|'proposed-api'|'off')
Git Gutter Integration
GitGutterManager supports 3 strategies: assume-unchanged (flags files via
git update-index), proposed-api (VS Code QuickDiffProvider), and off.
Debounced at 500ms with HEAD change detection to avoid redundant reapply.