DEV Community

Cover image for Building a Universal Drafts System in a VS Code Extension — Part 2: Sync, UI & Remote Drafts
freerave
freerave

Posted on

Building a Universal Drafts System in a VS Code Extension — Part 2: Sync, UI & Remote Drafts

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));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
    });
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!');
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    });
}
Enter fullscreen mode Exit fullscreen mode

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 ────────────────────────│                      │
Enter fullscreen mode Exit fullscreen mode

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:


Built by @FreeRave

Top comments (0)