Terminal.skills
Use Cases/Build a Browser Extension for Developer Productivity

Build a Browser Extension for Developer Productivity

Build a Chrome extension that saves code snippets from any webpage, organizes them with tags, and syncs across devices — using Plasmo for the extension framework with React and TypeScript.

Development#chrome-extension#browser#react#typescript#manifest-v3
Works with:claude-codeopenai-codexgemini-clicursor
$

The Problem

Ravi spends hours reading documentation, blog posts, and Stack Overflow answers. He highlights useful code snippets, copies them to a notes app, and never finds them again. He wants a browser extension that lets him save snippets directly from any webpage with one click, tag them, and search through them later — all without leaving the browser.

The Solution

Use the skills listed above to implement an automated workflow. Install the required skills:

bash
npx terminal-skills install plasmo

Step-by-Step Walkthrough

Step 1: Set Up the Extension

Plasmo handles all the Manifest V3 boilerplate — webpack config, service worker setup, content script injection — so Ravi can focus on the actual features.

bash
# Create the extension project with Tailwind CSS
pnpm create plasmo snippet-saver --with-tailwindcss
cd snippet-saver
pnpm dev   # Hot-reload extension in Chrome
tsx
// src/popup.tsx — Main popup UI when clicking the extension icon
import { useStorage } from "@plasmohq/storage/hook";
import { useState } from "react";

interface Snippet {
  id: string;
  code: string;
  language: string;
  source: string;       // URL where the snippet was saved from
  title: string;        // Page title
  tags: string[];
  savedAt: number;
  note: string;
}

function SnippetPopup() {
  const [snippets] = useStorage<Snippet[]>("snippets", []);
  const [searchQuery, setSearchQuery] = useState("");
  const [filterTag, setFilterTag] = useState<string | null>(null);

  // Derive all unique tags from saved snippets
  const allTags = [...new Set(snippets.flatMap((s) => s.tags))].sort();

  // Filter snippets by search query and selected tag
  const filtered = snippets.filter((snippet) => {
    const matchesSearch = !searchQuery ||
      snippet.code.toLowerCase().includes(searchQuery.toLowerCase()) ||
      snippet.note.toLowerCase().includes(searchQuery.toLowerCase()) ||
      snippet.tags.some((t) => t.includes(searchQuery.toLowerCase()));
    const matchesTag = !filterTag || snippet.tags.includes(filterTag);
    return matchesSearch && matchesTag;
  });

  return (
    <div className="w-[400px] max-h-[500px] overflow-y-auto p-4 bg-gray-900 text-white">
      <h1 className="text-lg font-bold mb-3">📋 Snippet Saver</h1>

      {/* Search bar */}
      <input
        type="text"
        value={searchQuery}
        onChange={(e) => setSearchQuery(e.target.value)}
        placeholder="Search snippets..."
        className="w-full px-3 py-2 bg-gray-800 rounded-md text-sm mb-2 outline-none focus:ring-2 focus:ring-blue-500"
      />

      {/* Tag filter pills */}
      <div className="flex flex-wrap gap-1 mb-3">
        {allTags.map((tag) => (
          <button
            key={tag}
            onClick={() => setFilterTag(filterTag === tag ? null : tag)}
            className={`text-xs px-2 py-1 rounded-full ${
              filterTag === tag ? "bg-blue-600" : "bg-gray-700 hover:bg-gray-600"
            }`}
          >
            {tag}
          </button>
        ))}
      </div>

      {/* Snippet list */}
      <div className="space-y-3">
        {filtered.length === 0 && (
          <p className="text-gray-400 text-sm text-center py-8">
            {snippets.length === 0
              ? "No snippets saved yet. Select code on any page and right-click → Save Snippet."
              : "No snippets match your search."}
          </p>
        )}
        {filtered.slice(0, 20).map((snippet) => (
          <SnippetCard key={snippet.id} snippet={snippet} />
        ))}
      </div>

      <div className="mt-3 pt-3 border-t border-gray-700 text-xs text-gray-400 text-center">
        {snippets.length} snippet{snippets.length !== 1 ? "s" : ""} saved
      </div>
    </div>
  );
}

function SnippetCard({ snippet }: { snippet: Snippet }) {
  const [copied, setCopied] = useState(false);

  const handleCopy = async () => {
    await navigator.clipboard.writeText(snippet.code);
    setCopied(true);
    setTimeout(() => setCopied(false), 1500);
  };

  return (
    <div className="bg-gray-800 rounded-lg p-3">
      <div className="flex justify-between items-start mb-2">
        <div className="flex-1 min-w-0">
          <span className="text-xs text-blue-400 font-mono">{snippet.language}</span>
          {snippet.note && (
            <p className="text-xs text-gray-300 mt-1 truncate">{snippet.note}</p>
          )}
        </div>
        <button
          onClick={handleCopy}
          className="ml-2 text-xs px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded"
        >
          {copied ? "✓" : "📋"}
        </button>
      </div>

      <pre className="text-xs font-mono bg-gray-900 p-2 rounded overflow-x-auto max-h-32">
        <code>{snippet.code.slice(0, 300)}{snippet.code.length > 300 ? "..." : ""}</code>
      </pre>

      <div className="flex justify-between items-center mt-2">
        <div className="flex gap-1">
          {snippet.tags.map((tag) => (
            <span key={tag} className="text-[10px] px-1.5 py-0.5 bg-gray-700 rounded">
              {tag}
            </span>
          ))}
        </div>
        <a
          href={snippet.source}
          target="_blank"
          rel="noopener"
          className="text-[10px] text-gray-500 hover:text-gray-300 truncate max-w-[150px]"
        >
          {new URL(snippet.source).hostname}
        </a>
      </div>
    </div>
  );
}

export default SnippetPopup;

Step 2: Content Script for Saving Snippets

The content script runs on every page and shows a save dialog when the user selects text and right-clicks. Plasmo's Shadow DOM isolation ensures the extension's CSS doesn't clash with the page.

tsx
// src/contents/snippet-saver.tsx — Content script overlay for saving snippets
import type { PlasmoCSConfig } from "plasmo";
import { Storage } from "@plasmohq/storage";
import { useState, useEffect } from "react";

export const config: PlasmoCSConfig = {
  matches: ["<all_urls>"],             // Run on all pages
};

const storage = new Storage();

function SnippetSaverOverlay() {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedText, setSelectedText] = useState("");
  const [language, setLanguage] = useState("text");
  const [tags, setTags] = useState("");
  const [note, setNote] = useState("");

  // Listen for messages from the background script (context menu click)
  useEffect(() => {
    const handler = (message: any) => {
      if (message.type === "SAVE_SNIPPET") {
        setSelectedText(message.text);
        setLanguage(detectLanguage(message.text));
        setIsOpen(true);
      }
    };
    chrome.runtime.onMessage.addListener(handler);
    return () => chrome.runtime.onMessage.removeListener(handler);
  }, []);

  const handleSave = async () => {
    const snippets = (await storage.get<any[]>("snippets")) ?? [];
    const newSnippet = {
      id: crypto.randomUUID(),
      code: selectedText,
      language,
      source: window.location.href,
      title: document.title,
      tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
      savedAt: Date.now(),
      note,
    };

    snippets.unshift(newSnippet);   // Newest first
    await storage.set("snippets", snippets);

    // Update badge count
    chrome.runtime.sendMessage({ type: "UPDATE_BADGE", count: snippets.length });

    setIsOpen(false);
    setTags("");
    setNote("");
  };

  if (!isOpen) return null;

  return (
    <div style={{
      position: "fixed", top: 20, right: 20, zIndex: 999999,
      width: 360, padding: 16, borderRadius: 12,
      backgroundColor: "#1e1e2e", color: "#cdd6f4",
      fontFamily: "system-ui, sans-serif", fontSize: 13,
      boxShadow: "0 8px 32px rgba(0,0,0,0.4)",
    }}>
      <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
        <strong>Save Snippet</strong>
        <button onClick={() => setIsOpen(false)} style={{ cursor: "pointer", background: "none", border: "none", color: "#cdd6f4" }}>✕</button>
      </div>

      <pre style={{
        background: "#11111b", padding: 8, borderRadius: 6,
        maxHeight: 120, overflow: "auto", fontSize: 11,
        fontFamily: "monospace", whiteSpace: "pre-wrap",
      }}>
        {selectedText.slice(0, 500)}
      </pre>

      <div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
        <select value={language} onChange={(e) => setLanguage(e.target.value)}
          style={{ padding: "6px 8px", borderRadius: 6, background: "#313244", color: "#cdd6f4", border: "none" }}>
          {["text", "javascript", "typescript", "python", "rust", "go", "bash", "sql", "css", "html", "json", "yaml"].map((lang) => (
            <option key={lang} value={lang}>{lang}</option>
          ))}
        </select>

        <input
          value={tags} onChange={(e) => setTags(e.target.value)}
          placeholder="Tags (comma-separated): react, hooks, state"
          style={{ padding: "6px 8px", borderRadius: 6, background: "#313244", color: "#cdd6f4", border: "none" }}
        />

        <input
          value={note} onChange={(e) => setNote(e.target.value)}
          placeholder="Quick note (optional)"
          style={{ padding: "6px 8px", borderRadius: 6, background: "#313244", color: "#cdd6f4", border: "none" }}
        />

        <button onClick={handleSave}
          style={{
            padding: "8px 16px", borderRadius: 6, border: "none", cursor: "pointer",
            background: "#89b4fa", color: "#1e1e2e", fontWeight: 600,
          }}>
          💾 Save Snippet
        </button>
      </div>
    </div>
  );
}

function detectLanguage(text: string): string {
  if (/^(import|export|const|let|var|function|=>)/.test(text)) return "javascript";
  if (/^(def |class |import |from |if __name__)/.test(text)) return "python";
  if (/^(fn |let mut |use |impl |struct |enum )/.test(text)) return "rust";
  if (/^(func |package |import \()/.test(text)) return "go";
  if (/^(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER)/i.test(text)) return "sql";
  if (/^\s*[#!].*\/bin\/(ba)?sh/.test(text)) return "bash";
  return "text";
}

export default SnippetSaverOverlay;

Step 3: Background Service Worker

The background script manages the context menu, badge counter, and optional sync to a remote API.

typescript
// src/background.ts — Background service worker
import { Storage } from "@plasmohq/storage";

const storage = new Storage();

// Create context menu on install
chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: "save-snippet",
    title: "💾 Save as Snippet",
    contexts: ["selection"],       // Only show when text is selected
  });
});

// Handle context menu click
chrome.contextMenus.onClicked.addListener((info, tab) => {
  if (info.menuItemId === "save-snippet" && info.selectionText && tab?.id) {
    // Send the selected text to the content script's overlay
    chrome.tabs.sendMessage(tab.id, {
      type: "SAVE_SNIPPET",
      text: info.selectionText,
    });
  }
});

// Handle messages from content scripts
chrome.runtime.onMessage.addListener((message) => {
  if (message.type === "UPDATE_BADGE") {
    const count = message.count;
    chrome.action.setBadgeText({ text: count > 0 ? String(count) : "" });
    chrome.action.setBadgeBackgroundColor({ color: "#89b4fa" });
  }
});

// Initialize badge count on startup
(async () => {
  const snippets = (await storage.get<any[]>("snippets")) ?? [];
  if (snippets.length > 0) {
    chrome.action.setBadgeText({ text: String(snippets.length) });
    chrome.action.setBadgeBackgroundColor({ color: "#89b4fa" });
  }
})();

// Optional: Periodic sync to a remote API
chrome.alarms.create("sync-snippets", { periodInMinutes: 60 });

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === "sync-snippets") {
    const snippets = (await storage.get<any[]>("snippets")) ?? [];
    const lastSync = (await storage.get<number>("lastSyncAt")) ?? 0;
    const newSnippets = snippets.filter((s) => s.savedAt > lastSync);

    if (newSnippets.length > 0) {
      try {
        await fetch("https://api.myapp.com/snippets/sync", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ snippets: newSnippets }),
        });
        await storage.set("lastSyncAt", Date.now());
      } catch {
        // Sync failed silently — will retry next interval
      }
    }
  }
});

Step 4: Export and Import

Ravi adds the ability to export snippets as a JSON file and import from other machines.

tsx
// src/options.tsx — Full-page options with export/import
import { useStorage } from "@plasmohq/storage/hook";

function OptionsPage() {
  const [snippets, setSnippets] = useStorage<any[]>("snippets", []);

  const handleExport = () => {
    const blob = new Blob([JSON.stringify(snippets, null, 2)], { type: "application/json" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `snippets-${new Date().toISOString().split("T")[0]}.json`;
    a.click();
    URL.revokeObjectURL(url);
  };

  const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    const text = await file.text();
    const imported = JSON.parse(text);
    // Merge, avoiding duplicates by ID
    const existingIds = new Set(snippets.map((s) => s.id));
    const newSnippets = imported.filter((s: any) => !existingIds.has(s.id));
    setSnippets([...newSnippets, ...snippets]);
  };

  const handleClearAll = () => {
    if (confirm(`Delete all ${snippets.length} snippets? This cannot be undone.`)) {
      setSnippets([]);
    }
  };

  return (
    <div style={{ maxWidth: 600, margin: "40px auto", padding: 20, fontFamily: "system-ui" }}>
      <h1>Snippet Saver Settings</h1>
      <p>{snippets.length} snippets saved</p>

      <div style={{ display: "flex", gap: 12, marginTop: 20 }}>
        <button onClick={handleExport}>📤 Export JSON</button>
        <label style={{ cursor: "pointer", padding: "8px 16px", background: "#eee", borderRadius: 6 }}>
          📥 Import JSON
          <input type="file" accept=".json" onChange={handleImport} style={{ display: "none" }} />
        </label>
        <button onClick={handleClearAll} style={{ background: "#fee", color: "#c00" }}>
          🗑️ Clear All
        </button>
      </div>
    </div>
  );
}

export default OptionsPage;

Real-World Example

Ravi published the extension internally for his team of 8 developers. In the first month, they collectively saved 1,200 snippets across JavaScript, Python, SQL, and Bash. The most popular tags turned out to be "regex", "docker", and "git" — exactly the kind of commands developers Google repeatedly.

The auto-language detection correctly identifies the language 80% of the time, saving a click on most saves. The search in the popup is instant since Plasmo's storage hook keeps all snippets in memory. The Chrome extension weighs just 45KB — Plasmo's tree-shaking strips everything unused.

The hourly sync to their team's API means snippets saved on a work laptop show up on the home machine within an hour. The export/import feature handles the edge case of switching browsers or sharing snippets with someone who doesn't use the extension.

Related Skills

  • plasmo -- Framework for building Chrome/Firefox extensions with React and TypeScript