chopstick
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"