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:
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.
# Create the extension project with Tailwind CSS
pnpm create plasmo snippet-saver --with-tailwindcss
cd snippet-saver
pnpm dev # Hot-reload extension in Chrome
// 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.
// 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.
// 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.
// 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