In Part 1, we built the Draft type, DraftsService, and the save/upsert flow.
Now for the interesting parts: loading a draft, keeping the Markdown editor in sync, pulling remote drafts from Dev.to, and building the UI.
Check out this 1-minute demo of the Two-Way Sync and Remote Drafts in action:
loadLocalDraft — The Two-Way Sync
This is the most important handler in the system.
When you click Load on a draft, the obvious thing to do is populate the WebView form fields. But that creates a mismatch: the WebView has the draft content, but the .md file in your editor still has whatever was there before. If you hit "Read Current File" or save the editor, you overwrite your draft.
The fix: loading a draft rewrites the active Markdown editor file at the same time. Both surfaces end up identical, always.
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 the draft data to the WebView form
this.view.webview.postMessage({ command: 'draftLoaded', draft });
this.sendInfo('Draft loaded!');
// Step 2: if it's an article, rewrite the active .md editor file
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 draft data
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 the entire document atomically
const doc = mdEditor.document;
const fullRange = new vscode.Range(
doc.positionAt(0),
doc.positionAt(doc.getText().length)
);
mdEditor.edit(editBuilder => editBuilder.replace(fullRange, content));
}
}
}
A few things to note here:
The frontmatter is reconstructed from the structured BlogPost object — not stored and replayed as raw text. This means the draft system and the frontmatter parser always agree on the schema. If a field is missing from the draft, it simply won't appear in the frontmatter rather than injecting a malformed line.
edit() is atomic from the user's perspective. Undo still works. The file on disk is not touched until the user saves manually with Ctrl+S. VS Code treats the whole replacement as a single edit operation in the history.
No visible .md editor? No problem. If there's no Markdown file open, the WebView still gets populated. The sync only fires when there's an editor to sync to.
fetchDevToDrafts — Remote Drafts
Local drafts solve the "WebView reset" problem. But there's another scenario: you saved a draft to Dev.to weeks ago and want to resume editing it in DotShare. That means pulling remote drafts from the API.
The handler calls fetchDevToArticles, which hits /api/articles/me/all?per_page=100 — returning both published and draft articles — 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,
});
}
isRemote: true is the key flag. The WebView checks it before offering any "Save Locally" action — remote drafts are updated via PUT /api/articles/:id, not written to globalState. Clicking Load on a remote draft still triggers the full two-way sync, so the article body lands in both the WebView and the Markdown editor simultaneously.
The updateDevToArticle handler wraps the Dev.to update API:
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 remote list
}
The Reset Boilerplate Button
A small feature with outsized usefulness. One click wipes both the WebView form and the Markdown editor back to a clean template. No manual selection, no Ctrl+A Delete, no hunting for leftover frontmatter fields.
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 the WebView form 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!');
}
The WebView UI
The drafts section lives at the bottom of every platform panel, after the workspace composer. The HTML uses {{PLATFORM_NAME}} tokens that get injected by DotShareWebView._buildPlatformHtml() at panel creation time:
<div class="drafts-section">
<h3>📝 Saved Drafts</h3>
<p>Drafts for {{PLATFORM_NAME}}</p>
<div id="drafts-loading" class="empty-state">Loading drafts...</div>
<div id="drafts-empty" class="empty-state" style="display:none;">
<div class="empty-icon">📝</div>
<div>No local drafts yet</div>
</div>
<div id="drafts-list" class="drafts-grid" style="display:none;">
<!-- Rendered by JS -->
</div>
<!-- Remote drafts — only shown for Dev.to -->
<div id="remote-drafts-container" style="display:none; margin-top:16px;">
<h3>🌐 Remote Drafts</h3>
<button class="btn btn-secondary btn-sm" id="btn-fetch-remote-drafts">
Fetch Remote Drafts
</button>
<div id="remote-drafts-list" class="drafts-grid" style="display:none;"></div>
</div>
</div>
Draft cards are rendered dynamically by the WebView JS and styled using VS Code's native CSS variables — they adapt to any theme automatically:
.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);
}
/* Badge colors */
.draft-badge.article { background: #8b5cf6; } /* purple = local article */
.draft-badge.remote { background: #10b981; } /* green = remote Dev.to */
/* Active draft gets a focus ring */
.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);
}
The border-left: 3px solid accent pattern is borrowed from VS Code's own Problems panel — instantly familiar to VS Code users without any learning curve.
Split-Editor Workflow
When you click Create Post for Dev.to or Medium, the extension creates (or opens) a named .md file and shows it in a side-by-side split next to the WebView panel:
if (config.workspaceType === 'blogs') {
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspacePath) return;
const mdFilePath = path.join(workspacePath, `dotshare-${platformKey}.md`);
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 = Column One, Markdown = Beside it
vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside);
});
}
The file is named — dotshare-devto.md, dotshare-medium.md — not untitled. Named files persist across VS Code restarts, work naturally with git, and give you a free version-controlled article history. You can also open the file in any external editor and the Two-Way Sync will pick up the changes next time you hit "Read Current File."
The Full Flow in One Diagram
WebView MessageHandler PostHandler DraftsService / API
│ │ │ │
│─ saveLocalDraft ────────►│ │ │
│ │─ includes('Draft')►│ │
│ │ │─ saveDraft() ────────►│
│ │ │◄── Draft ────────────│
│◄─ draftLoaded ───────────────────────────────│ │
│◄─ draftsLoaded ──────────────────────────────│ │
│ │ │ │
│─ loadLocalDraft ────────►│ │ │
│ │ │─ getDraft() ─────────►│
│◄─ draftLoaded ───────────────────────────────│ │
│ │ mdEditor.edit() ◄──── Two-Way Sync
│ │ │ │
│─ fetchDevToDrafts ──────►│ │ │
│ │ │─ fetchDevToArticles()─►Dev.to API
│◄─ remoteDraftsLoaded ────────────────────────│ │
What's Next
The system solves the core problem. A few things I plan to add:
- Auto-save on blur — checkpoint the draft every time the textarea loses focus, no button press needed
- Draft preview — show the first 3 lines of the article inline in the draft card
- Conflict detection — when a remote Dev.to draft differs from a local copy, show a diff
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
Built by @FreeRave
Top comments (0)