All widgets
Clipboard History
Comes with Chopstick
The last 40 things you copied: text, links, images and files. Pick one to copy it again. Passwords that password managers mark as concealed are never kept.
Already installed? It's in your widget library.



Type it in the notch's search
- Show clipboard history
- Search clipboard for query
- Clear clipboard history
What it runs
A widget is a folder of plain files. Here is all of its code.
widget.json
{
"id": "clipboard",
"name": "Clipboard History",
"icon": "icon.png",
"description": "The last 40 things you copied: text, links, images and files. Pick one to copy it again. Passwords that password managers mark as concealed are never kept.",
"shortcut": "ctrl+opt+v",
"anytime": true,
"initial": "history",
"watch": [
{
"script": "capture.sh",
"on": [
"clipboard"
],
"always": true,
"as": "clip"
}
],
"commands": {
"show": {
"description": "Show what was copied recently.",
"phrase": "Show clipboard history",
"do": [
{
"go": "history"
}
]
},
"search": {
"description": "Find something copied earlier.",
"phrase": "Search clipboard for {query}",
"inputs": {
"query": {
"type": "string",
"description": "Words in the text, file name or path"
}
},
"do": [
{
"set": {
"query": "{{input.query}}"
}
},
{
"go": "found"
}
]
},
"clear": {
"description": "Forget everything in the clipboard history.",
"phrase": "Clear clipboard history",
"do": [
{
"run": "clear.sh"
},
{
"refresh": "clip"
},
"dismiss"
]
}
},
"states": {
"history": {
"compact": {
"leading": {
"symbol": "list.clipboard",
"tint": "blue"
},
"trailing": {
"text": "{{clip.count | }}"
}
},
"expanded": {
"size": "regular",
"header": {
"symbol": "list.clipboard",
"tint": "blue",
"title": "Clipboard"
},
"blocks": [
{
"input": {
"placeholder": "Search what you copied",
"do": [
{
"set": {
"query": "{{item.text}}"
}
},
{
"go": "found"
}
]
}
},
{
"list": {
"from": "clip.items",
"limit": 8,
"symbol": "{{item.symbol}}",
"image": "{{item.image}}",
"title": "{{item.title}}",
"subtitle": "{{item.subtitle}}",
"do": [
{
"set": {
"pick": "{{item.id}}"
}
},
{
"run": "copy.sh"
},
{
"go": "copied"
}
]
}
},
{
"note": "{{clip.none}}"
}
]
},
"keys": [
{
"key": "cmd+delete",
"label": "Clear",
"do": [
{
"run": "clear.sh"
},
{
"refresh": "clip"
}
]
}
]
},
"found": {
"task": true,
"open": true,
"on_enter": [
{
"run": "search.sh"
}
],
"compact": {
"leading": {
"symbol": "magnifyingglass",
"tint": "blue"
},
"trailing": {
"text": "{{result.count | …}}"
}
},
"expanded": {
"size": "regular",
"header": {
"symbol": "list.clipboard",
"tint": "blue",
"title": "Clipboard"
},
"blocks": [
{
"input": {
"placeholder": "Search what you copied",
"value": "{{query}}",
"do": [
{
"set": {
"query": "{{item.text}}"
}
},
{
"run": "search.sh"
}
]
}
},
{
"list": {
"from": "result.items",
"limit": 8,
"symbol": "{{item.symbol}}",
"image": "{{item.image}}",
"title": "{{item.title}}",
"subtitle": "{{item.subtitle}}",
"do": [
{
"set": {
"pick": "{{item.id}}"
}
},
{
"run": "copy.sh"
},
{
"go": "copied"
}
]
}
},
{
"note": "{{result.none}}"
}
]
},
"keys": [
{
"key": "esc",
"do": [
{
"go": "history"
}
]
}
]
},
"copied": {
"open": false,
"dismiss_after": 2,
"compact": {
"leading": {
"symbol": "checkmark.circle.fill",
"tint": "green"
},
"trailing": {
"text": "Copied"
}
}
}
},
"preview": {
"states": [
"history",
"found",
"copied"
],
"data": {
"clip": {
"count": 4,
"none": "",
"items": [
{
"id": "1",
"kind": "text",
"title": "https://github.com/joshuaknauber/chopstick/pull/42",
"subtitle": "52 characters · 14:05",
"symbol": "link",
"image": ""
},
{
"id": "2",
"kind": "image",
"title": "Image 1440×900",
"subtitle": "312 KB · 14:02",
"symbol": "photo",
"image": "icon.png"
},
{
"id": "3",
"kind": "files",
"title": "Q3 report.pdf",
"subtitle": "~/Documents/Q3 report.pdf · 13:58",
"symbol": "doc",
"image": ""
},
{
"id": "4",
"kind": "text",
"title": "func searchWidgets(_ query: String,",
"subtitle": "12 lines · 13:40",
"symbol": "doc.plaintext",
"image": ""
}
]
},
"query": "report",
"result": {
"count": 1,
"none": "",
"items": [
{
"id": "3",
"kind": "files",
"title": "Q3 report.pdf",
"subtitle": "~/Documents/Q3 report.pdf · 13:58",
"symbol": "doc",
"image": ""
}
]
}
}
}
}
capture.sh
#!/bin/sh # Records a new copy (see history.js) and prints the history. exec osascript -l JavaScript history.js capture
clear.sh
#!/bin/sh # Forgets every copy. exec osascript -l JavaScript history.js clear
copy.sh
#!/bin/sh # Puts the picked item ($CHOPSTICK_PICK) back on the clipboard. exec osascript -l JavaScript history.js copy "$CHOPSTICK_PICK"
history.js
// Clipboard history, kept in $CHOPSTICK_STORAGE: history.json (newest first) and a PNG per copied image.
// `osascript -l JavaScript history.js capture` records what's on the clipboard now (unless it was
// already recorded, or a password manager marked it concealed) and prints the list;
// `copy <id>` puts an item back on the clipboard; `search <words>` prints the items that match;
// `clear` forgets everything.
ObjC.import('AppKit')
ObjC.import('stdlib')
const MAX = 40, SHOWN = 30, TEXT = 10000
const env = name => { const v = $.getenv(name); return v ? ObjC.unwrap($.NSString.stringWithUTF8String(v)) : '' }
const storage = env('CHOPSTICK_STORAGE')
const fm = $.NSFileManager.defaultManager
const file = name => storage + '/' + name
const board = $.NSPasteboard.generalPasteboard
function read(name, fallback) {
const text = $.NSString.stringWithContentsOfFileEncodingError(file(name), $.NSUTF8StringEncoding, null)
if (text.isNil()) return fallback
try { return JSON.parse(ObjC.unwrap(text)) } catch (e) { return fallback }
}
function write(name, value) {
$(JSON.stringify(value)).writeToFileAtomicallyEncodingError(file(name), true, $.NSUTF8StringEncoding, null)
}
const concealed = ['org.nspasteboard.ConcealedType', 'org.nspasteboard.TransientType', 'org.nspasteboard.AutoGeneratedType']
const imageTypes = ['png', 'jpg', 'jpeg', 'gif', 'heic', 'tiff', 'tif', 'webp', 'bmp']
const time = () => { const d = new Date(); return ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2) }
const short = path => path.replace(env('HOME'), '~')
// What's on the clipboard as an item, or null for nothing worth keeping.
function current() {
const types = ObjC.deepUnwrap(board.types) || []
if (types.some(t => concealed.includes(t))) return null
if (types.includes('public.file-url')) {
const urls = board.readObjectsForClassesOptions($([$.NSURL]), $({}))
const paths = []
for (let i = 0; !urls.isNil() && i < urls.count; i++) paths.push(ObjC.unwrap(urls.objectAtIndex(i).path))
if (paths.length) {
const names = paths.map(p => p.split('/').pop())
const one = paths.length === 1, ext = names[0].split('.').pop().toLowerCase()
const folder = one && $.NSURL.fileURLWithPath(paths[0]).hasDirectoryPath
return { kind: 'files', key: 'files:' + paths.join('\n'), paths,
title: one ? names[0] : names.length + ' files: ' + names.join(', '),
detail: one ? short(paths[0]) : short(paths[0].split('/').slice(0, -1).join('/')),
symbol: folder ? 'folder' : one ? 'doc' : 'doc.on.doc',
image: one && imageTypes.includes(ext) ? paths[0] : '' }
}
}
const png = board.dataForType('public.png'), tiff = board.dataForType('public.tiff')
if (!png.isNil() || !tiff.isNil()) {
const data = png.isNil() ? $.NSBitmapImageRep.imageRepWithData(tiff).representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({})) : png
const rep = $.NSBitmapImageRep.imageRepWithData(data)
const size = rep.isNil() ? '' : rep.pixelsWide + '×' + rep.pixelsHigh
return { kind: 'image', key: 'image:' + data.length + ':' + size, data,
title: 'Image' + (size ? ' ' + size : ''), detail: Math.round(data.length / 1024) + ' KB', symbol: 'photo' }
}
const string = board.stringForType('public.utf8-plain-text')
if (!string.isNil()) {
const text = ObjC.unwrap(string).slice(0, TEXT)
if (!text.trim()) return null
const line = text.trim().split('\n')[0].slice(0, 120)
const lines = text.trim().split('\n').length
return { kind: 'text', key: 'text:' + text, text, title: line,
detail: lines > 1 ? lines + ' lines' : text.length + ' characters',
symbol: /^https?:\/\/\S+$/.test(text.trim()) ? 'link' : 'doc.plaintext' }
}
return null
}
function capture() {
const items = read('history.json', [])
const seen = read('seen.json', { count: -1 })
const count = board.changeCount
if (seen.count === count) return items
write('seen.json', { count })
const item = current()
if (!item) return items
// Copied again: moves to the top instead of showing twice.
const old = items.findIndex(i => i.key === item.key)
const kept = old >= 0 ? items.splice(old, 1)[0] : null
item.id = kept ? kept.id : String(Date.now())
item.time = time()
if (item.kind === 'image') {
item.image = kept ? kept.image : file('image-' + item.id + '.png')
if (!kept) item.data.writeToFileAtomically(item.image, true)
delete item.data
}
items.unshift(item)
for (const gone of items.splice(MAX)) {
if (gone.kind === 'image') fm.removeItemAtPathError(gone.image, null)
}
write('history.json', items)
return items
}
function copy(id) {
const item = read('history.json', []).find(i => i.id === id)
if (!item) throw new Error("That's no longer in the clipboard history")
board.clearContents
if (item.kind === 'text') board.setStringForType($(item.text), 'public.utf8-plain-text')
else if (item.kind === 'image') {
const data = $.NSData.dataWithContentsOfFile(item.image)
if (data.isNil()) throw new Error('The image is gone')
board.setDataForType(data, 'public.png')
const image = $.NSImage.alloc.initWithData(data)
if (!image.isNil()) board.setDataForType(image.TIFFRepresentation, 'public.tiff')
} else {
board.writeObjects($(item.paths.map(p => $.NSURL.fileURLWithPath(p))))
}
return item
}
function shown(items) {
return items.slice(0, SHOWN).map(({ id, kind, title, detail, symbol, image, time }) =>
({ id, kind, title, subtitle: detail + ' · ' + time, symbol, image: image || '' }))
}
function run(argv) {
fm.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(storage, true, $(), null)
const [command, ...rest] = argv
try {
if (command === 'copy') {
const item = copy(rest[0])
return JSON.stringify({ title: item.title, kind: item.kind })
}
if (command === 'clear') {
for (const item of read('history.json', [])) if (item.kind === 'image') fm.removeItemAtPathError(item.image, null)
write('history.json', [])
return JSON.stringify({ count: 0, items: [] })
}
const items = command === 'capture' ? capture() : read('history.json', [])
if (command === 'search') {
const words = rest.join(' ').toLowerCase().split(/\s+/).filter(Boolean)
const found = items.filter(i => {
const text = [i.title, i.text, (i.paths || []).join(' ')].join(' ').toLowerCase()
return words.every(w => text.includes(w))
})
return JSON.stringify({ count: found.length, items: shown(found), none: found.length ? '' : 'Nothing copied matches.' })
}
return JSON.stringify({ count: items.length, items: shown(items), none: items.length ? '' : 'Copy something and it shows up here.' })
} catch (e) {
console.log(e.message)
$.exit(1)
}
}
search.sh
#!/bin/sh # The items matching $CHOPSTICK_QUERY. exec osascript -l JavaScript history.js search "$CHOPSTICK_QUERY"