Two-Way Markdown Sync in a VS Code Extension — How DotShare Keeps Two Editors in Lockstep

How DotShare v3.2.5 rewrites an active Markdown editor file atomically when loading a draft, pulls remote Dev.to articles into the same UI, and auto-creates a named .md file on panel open.
In Part 1, I covered the wrong architecture decisions behind DotShare's draft system and what replaced them — the union types, globalState over files, the upsert pattern.
This is Part 2: the implementation. Specifically, the part that was harder than it looked — keeping a WebView form and a Markdown editor in sync without either one becoming the source of truth.
Check out this 1-minute demo of the Two-Way Sync and Remote Drafts in action
The Sync Problem
DotShare's blogging workspace has two surfaces:
A WebView panel with form fields — title, tags, description, cover image, body textarea
A Markdown editor open beside it —
dotshare-devto.mdwith YAML frontmatter and article body
Both represent the same article. They need to stay identical.
The naive approach: WebView is the source of truth, Markdown is a display. Problem: DotShare has a "Read Current File" button that reads the active .md file and populates the WebView. If the Markdown file is out of date, clicking it overwrites whatever the user has in the form.
The right approach: loading a draft updates both surfaces at once. The WebView gets the form fields. The Markdown editor gets a freshly reconstructed file. Neither can go stale.
handleLoadLocalDraft — The Implementation
private async handleLoadLocalDraft(message: Message): Promise<void> {
const draftId = message.draftId as string;
if (!draftId) return;
const draft = this.draftsService.getDraft(draftId);
if (!draft) {
this.sendError('Draft not found.');
return;
}
// Step 1: push structured data to WebView form fields
this.view.webview.postMessage({ command: 'draftLoaded', draft });
this.sendInfo('Draft loaded!');
// Step 2: rewrite the active Markdown editor (articles only)
if (draft.type === 'article') {
const mdEditor = vscode.window.visibleTextEditors.find(
e => e.document.languageId === 'markdown'
);
if (mdEditor) {
const data = draft.data as BlogPost;
// Reconstruct YAML frontmatter from structured BlogPost fields
let content = '---\n';
content += `title: ${data.title || 'Untitled'}\n`;
if (data.tags?.length) content += `tags: [${data.tags.join(', ')}]\n`;
content += `published: ${data.status === 'published'}\n`;
if (data.description) content += `description: ${data.description}\n`;
if (data.coverImage) content += `cover_image: ${data.coverImage}\n`;
if (data.canonicalUrl) content += `canonical_url: ${data.canonicalUrl}\n`;
if (data.series) content += `series: ${data.series}\n`;
content += '---\n';
content += data.bodyMarkdown || '';
// Replace full document in one atomic edit
const doc = mdEditor.document;
const fullRange = new vscode.Range(
doc.positionAt(0),
doc.positionAt(doc.getText().length)
);
mdEditor.edit(editBuilder => editBuilder.replace(fullRange, content));
}
}
}
Three things worth unpacking:
Frontmatter is reconstructed, not stored as raw text. The draft stores a BlogPost object with typed fields. When loading, those fields are serialized into valid YAML. This means the draft system and the frontmatter parser always agree on the schema — a field missing from the draft produces a missing YAML line, not a malformed one.
mdEditor.edit() is atomic. VS Code treats the entire replacement as a single undo entry. The user can Ctrl+Z back to the previous state cleanly. The file on disk is not touched until they explicitly save.
No visible editor? No problem. The .find() call returns undefined if there's no Markdown file open. The WebView still gets populated — the editor sync is additive, not required.
Remote Drafts — Fetching From Dev.to
Local drafts solve the WebView reset problem. Remote drafts solve a different problem: you published a draft to Dev.to three weeks ago and want to resume editing it in DotShare.
The handler calls fetchDevToArticles — which hits /api/articles/me/all?per_page=100 — and maps each result to the same Draft interface:
private async handleFetchDevToDrafts(): Promise<void> {
const devtoApiKey = await this.context.secrets.get('devtoApiKey') || '';
if (!devtoApiKey) {
this.sendError('Dev.to API Key not configured.');
return;
}
const articles = await fetchDevToArticles(devtoApiKey);
const drafts = articles.map(a => ({
id: `devto_${a.id}`,
type: 'article' as const,
timestamp: a.published_at || new Date().toISOString(),
platforms: ['devto'] as SocialPlatform[],
title: a.title,
isRemote: true,
remoteId: a.id?.toString(),
data: {
title: a.title,
bodyMarkdown: a.body_markdown || '',
tags: a.tags || [],
status: a.published ? 'published' : 'draft',
platformId: 'devto',
url: a.url,
canonicalUrl: a.canonical_url,
coverImage: a.cover_image,
description: a.description,
} as BlogPost,
}));
this.view.webview.postMessage({
command: 'remoteDraftsLoaded',
platform: 'devto',
drafts,
});
}
Because remote drafts use the same Draft interface, loading one triggers the exact same two-way sync. The Dev.to article body lands in the WebView and the Markdown editor simultaneously — no special handling, no branch.
Updating a remote draft goes through a separate handler that calls PUT /api/articles/:id:
private async handleUpdateDevToArticle(message: Message): Promise<void> {
const devtoApiKey = await this.context.secrets.get('devtoApiKey') || '';
const remoteId = message.remoteId as string;
const data = message.data as Partial<BlogPost>;
const result = await updateDevToArticle(devtoApiKey, parseInt(remoteId, 10), {
text: data.bodyMarkdown ?? '',
title: data.title,
tags: data.tags,
published: data.status === 'published',
description: data.description,
coverImage: data.coverImage,
canonicalUrl: data.canonicalUrl,
series: data.series,
});
this.sendSuccess(`Updated on Dev.to — ${result.url}`);
await this.handleFetchDevToDrafts(); // refresh the list
}
The local edit never replaces the remote article without an explicit Update action. No silent overwrites.
Split-Editor Workflow
When you click Create Post for Dev.to or Medium, the extension creates a named .md file in your workspace and opens it beside the WebView — automatically, no extra steps:
if (config.workspaceType === 'blogs') {
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspacePath) return;
const mdFilePath = path.join(workspacePath, `dotshare-${platformKey}.md`);
// Create with boilerplate if it doesn't exist yet
if (!fs.existsSync(mdFilePath)) {
fs.writeFileSync(mdFilePath, `---
title: add ur title
tags: [add, tags, max, 4]
published: false
description: add ur description
---
Start writing your article here...
`, 'utf8');
}
vscode.workspace.openTextDocument(mdFilePath).then(doc => {
// WebView stays in Column One, .md file opens Beside
vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside);
});
}
Named files — dotshare-devto.md, dotshare-medium.md — not untitled buffers. This matters for three reasons:
They survive VS Code restarts. Your article is there when you come back.
They work with git. Your article history is version controlled automatically — even if you never think about it.
They're a fallback. If
globalStatewas somehow wiped, the last-saved article content is still sitting in the.mdfile in your workspace.
Reset Boilerplate
A button I almost didn't ship that turned out to be one of the most-used features in the blogging workspace.
After publishing an article, starting fresh meant manually clearing six different fields — title, tags, description, cover image, canonical URL, body. Slow and error-prone. Users would leave placeholder text in the body and accidentally publish it.
One button, one operation:
private async handleResetBlogMarkdown(): Promise<void> {
const mdEditor = vscode.window.visibleTextEditors.find(
e => e.document.languageId === 'markdown'
);
if (!mdEditor) {
this.sendError('No active markdown file found to reset.');
return;
}
const boilerplate = `---
title: add ur title
tags: [add, tags, max, 4]
published: false
description: add ur description
---
Start writing your article here...
`;
const doc = mdEditor.document;
const fullRange = new vscode.Range(
doc.positionAt(0),
doc.positionAt(doc.getText().length)
);
mdEditor.edit(editBuilder => editBuilder.replace(fullRange, boilerplate));
// Sync WebView too
this.view.webview.postMessage({
command: 'updateBlogFrontmatter',
frontmatter: {
title: 'add ur title',
tags: ['add', 'tags', 'max', '4'],
published: false,
description: 'add ur description',
},
});
this.view.webview.postMessage({
command: 'updatePost',
post: 'Start writing your article here...',
});
this.sendSuccess('Markdown boilerplate reset!');
}
Both surfaces. One action. Clean state.
The WebView Draft Grid
Draft cards render at the bottom of every platform panel using VS Code's native CSS variables — they adapt to any theme without media queries or JS:
.draft-card {
background: var(--vscode-editorWorkspace-background, #1e1e1e);
border: 1px solid var(--vscode-editorWidget-border);
border-left: 3px solid var(--vscode-button-background);
border-radius: 8px;
padding: 12px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.draft-card:hover {
border-color: var(--vscode-focusBorder);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
/* purple = local article, green = remote Dev.to */
.draft-badge.article { background: #8b5cf6; }
.draft-badge.remote { background: #10b981; }
.draft-card--active {
border-left-color: var(--vscode-focusBorder, #6c63ff);
box-shadow: 0 0 0 1px var(--vscode-focusBorder, #6c63ff),
0 2px 8px rgba(108, 99, 255, 0.15);
}
Color distinguishes local from remote at a glance. The border-left: 3px solid accent is borrowed from VS Code's Problems panel — instantly familiar to anyone who's used VS Code for more than a day.
The Complete Flow
[💾 Save Draft]
│
▼
WebView: { command: 'saveLocalDraft', draft, draftId? }
│
▼
PostHandler → upsert → DraftsService.globalState
│
▼
postMessage: draftLoaded + draftsLoaded
[📂 Load]
│
▼
WebView: { command: 'loadLocalDraft', draftId }
│
▼
PostHandler → DraftsService.getDraft()
├── postMessage: draftLoaded → WebView form fields
└── mdEditor.edit() → .md file rewritten
(Two-Way Sync)
[🌐 Fetch Remote]
│
▼
PostHandler → fetchDevToArticles(apiKey) → Dev.to API
│
▼
postMessage: remoteDraftsLoaded
What I'd Add Next
Auto-save on blur. Save a checkpoint every time the textarea loses focus. The explicit Save button is good for intentional saves — but most data loss happens when users forget to press it.
Per-draft key storage. The current implementation stores all drafts as a single JSON array. That's fine for dozens of drafts but would degrade with hundreds. Storing each draft under dotshare_draft_${id} with a separate index key would make individual reads O(1) instead of loading the full array.
Conflict detection. When a remote Dev.to draft differs from a local copy of the same article, show a diff instead of silently overwriting.
Try It
DotShare is free and open source under Apache 2.0. Try it out on your preferred registry:
VS Code Marketplace: Install DotShare (or run
ext install freerave.dotshare)Open VSX Registry: Available here (for VSCodium users)
GitHub: github.com/kareem2099/DotShare





