Compare commits

...

10 Commits

Author SHA1 Message Date
alan a979a6b143 Add rich previews for copied files 2026-09-09 11:09:35 +01:00
alan f4fc81eefe Bound watcher stdin draining with a deadline 2026-09-08 18:06:43 +01:00
alan 4522fdbce1 Bound clipboard payloads and image parsing
The capture daemon read the whole clipboard payload into memory and handed
it to Pillow, zbarimg and tesseract with no limit. Payloads now stream
through a capped reader (32 MiB images, 4 MiB text, 5 s deadline) and are
dropped when exceeded; image dimensions come from the container header
without decoding, and QR/OCR only run under 40 megapixels. The OCR backfill
script applies the same pixel guard. Limits are overridable via
CLIPBOARD_MAX_IMAGE_BYTES, CLIPBOARD_MAX_TEXT_BYTES, CLIPBOARD_MAX_PARSE_PIXELS.
2026-09-08 15:20:10 +01:00
alan b1ebdb83e6 List is strictly newest-first; search only filters
Drop the recency/pin/usage score bonuses that reordered the picker: an
entry pasted before outranked a fresh copy for hours. Results are now
sorted by timestamp only; query tokens and fuzzy terms filter rows.
2026-09-08 10:59:34 +01:00
alan b1d98971c9 Scrollable OCR panel and copy-text shortcut
The OCR panel overflowed its container on long recognized text; it now
clips, caps at 40% of the preview height, and scrolls. Ctrl+Shift+C copies
an entry's text content (OCR text or QR payload for images) as plain text,
and the QR/OCR panels get Copy chips.
2026-09-07 16:50:11 +01:00
alan 8127ae4bba Fix text body lingering under image preview
PreviewPane set bodyText from onResultChanged, reading derived/entry that
are themselves bindings on result. Depending on evaluation order the
handler saw the previous entry, leaving a stale text body visible under
the image column. Derive bodyText declaratively and gate the text body on
the textual types.
2026-09-07 16:28:06 +01:00
alan 4662c8002d Prepare marketplace submission: preview image, dependency docs, drop personal upgrade script 2026-09-07 12:23:37 +01:00
alan 4f562ab215 Add legacy upgrade helper 2026-09-06 00:59:48 +01:00
alan 2c999201db Add legacy install and shortcut migration 2026-09-06 00:51:40 +01:00
alan 1d7a9d6cde Keep clipboard shortcuts routed to built-in source 2026-09-06 00:47:44 +01:00
15 changed files with 862 additions and 174 deletions
+24 -14
View File
@@ -10,7 +10,7 @@ import "Classify.js" as Classify
// Clipboard history picker — Raycast-style: fuzzy search bar, result list, // Clipboard history picker — Raycast-style: fuzzy search bar, result list,
// and a per-type preview pane. Clone of omarchy.clipboard with richer // and a per-type preview pane. Clone of omarchy.clipboard with richer
// capture metadata (app, size, dims, pins, usage) and full-text fuzzy search. // capture metadata (app, size, dims, pins, usage) and full-text fuzzy filtering.
Item { Item {
id: root id: root
@@ -83,20 +83,8 @@ Item {
else root.open() else root.open()
} }
// Direct target for explicit bindings and pause/resume automation.
IpcHandler {
target: "alanfortlink.clipboard"
function open(): void { root.open() }
function close(): void { root.close() }
function show(): void { root.open() }
function hide(): void { root.close() }
function toggle(): void { root.toggle() }
function pause(payloadJson: string): string { return root.pause(payloadJson) }
function isPaused(): string { return root.isPaused() }
}
// ------------------------------------------------------------ pause // ------------------------------------------------------------ pause
// Toggle via IPC: omarchy-shell alanfortlink.clipboard pause '{"paused":true}' // Toggle via IPC: omarchy-shell shell call alanfortlink.clipboard pause '{"paused":true}'
// (or {"paused":false}, or {"paused":"toggle"}), and Ctrl+Space in the picker. // (or {"paused":false}, or {"paused":"toggle"}), and Ctrl+Space in the picker.
function setPaused(next) { function setPaused(next) {
root.paused = !!next root.paused = !!next
@@ -280,6 +268,22 @@ Item {
Quickshell.execDetached([root.pluginDir + "/open-entry.sh", result.row.entry.id]) Quickshell.execDetached([root.pluginDir + "/open-entry.sh", result.row.entry.id])
} }
// Text content of an entry as a string: the text itself, or for images the
// recognized (OCR) text, falling back to a decoded QR payload.
function textContent(result) {
if (!result) return ""
var e = result.row.entry
if (e.type === "image") return String(e.ocr || e.qr || "")
return String(e.text || "")
}
// Copy a plain string (not an entry) to the clipboard and close.
function copyText(text) {
if (!text) return
root.close()
Quickshell.execDetached(["wl-copy", "--type", "text/plain", "--", text])
}
function removeIndex(index) { function removeIndex(index) {
if (index < 0 || index >= root.results.length) return if (index < 0 || index >= root.results.length) return
var entry = root.results[index].row.entry var entry = root.results[index].row.entry
@@ -572,6 +576,9 @@ Item {
} else if (event.key === Qt.Key_O && (event.modifiers & Qt.ControlModifier)) { } else if (event.key === Qt.Key_O && (event.modifiers & Qt.ControlModifier)) {
root.openResult(root.currentResult) root.openResult(root.currentResult)
event.accepted = true event.accepted = true
} else if (event.key === Qt.Key_C && (event.modifiers & Qt.ControlModifier) && (event.modifiers & Qt.ShiftModifier)) {
root.copyText(root.textContent(root.currentResult))
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (event.modifiers & Qt.ShiftModifier) root.copyResult(root.currentResult) if (event.modifiers & Qt.ShiftModifier) root.copyResult(root.currentResult)
else if (event.modifiers & Qt.AltModifier) root.openResult(root.currentResult) else if (event.modifiers & Qt.AltModifier) root.openResult(root.currentResult)
@@ -939,6 +946,8 @@ Item {
anchors.leftMargin: root.listWidth + Style.space(14) anchors.leftMargin: root.listWidth + Style.space(14)
result: root.currentResult result: root.currentResult
openAction: function() { root.openResult(root.currentResult) } openAction: function() { root.openResult(root.currentResult) }
copyTextAction: function(text) { root.copyText(text) }
pluginDir: root.pluginDir
visible: root.currentResult !== null visible: root.currentResult !== null
} }
@@ -984,6 +993,7 @@ Item {
{ keys: "enter", hint: "paste" }, { keys: "enter", hint: "paste" },
{ keys: "shift+enter", hint: "copy" }, { keys: "shift+enter", hint: "copy" },
{ keys: "ctrl+o", hint: "open" }, { keys: "ctrl+o", hint: "open" },
{ keys: "ctrl+shift+c", hint: "copy text" },
{ keys: "tab", hint: "pin" }, { keys: "tab", hint: "pin" },
{ keys: "ctrl+=", hint: "pause" }, { keys: "ctrl+=", hint: "pause" },
{ keys: "del", hint: "remove" }, { keys: "del", hint: "remove" },
+5 -8
View File
@@ -153,7 +153,7 @@ function fuzzyMatch(needle, haystack) {
score += 8 score += 8
} }
// Gap penalty (light — we rank mostly by bonuses and recency). // Gap penalty (light).
if (k > 0) { if (k > 0) {
var gap = pos - positions[k - 1] - 1 var gap = pos - positions[k - 1] - 1
if (gap > 0) score -= Math.min(6, gap) if (gap > 0) score -= Math.min(6, gap)
@@ -227,7 +227,7 @@ function recencyBonus(ts, now) {
} }
// rows: [{ entry, content, app, type, ts, pinned, uses, bytes }] // rows: [{ entry, content, app, type, ts, pinned, uses, bytes }]
// Returns up to `limit` rows sorted by relevance: [{ row, score, positions, type }] // Returns up to `limit` matching rows, newest first: [{ row, score, positions, type }]
function searchRows(rows, queryStr, now, limit) { function searchRows(rows, queryStr, now, limit) {
var parsed = parseQuery(queryStr) var parsed = parseQuery(queryStr)
var results = [] var results = []
@@ -255,11 +255,9 @@ function searchRows(rows, queryStr, now, limit) {
if (!matched) continue if (!matched) continue
} }
var score = matched ? matched.score : 0 // Search only filters. The list is always ordered newest-first; no
score += recencyBonus(row.ts, now) // relevance, pin, or usage ranking.
if (row.entry.pinned) score += 500 var score = 0
if (row.uses > 0) score += 4 * Math.min(10, row.uses)
results.push({ results.push({
row: row, row: row,
score: score, score: score,
@@ -268,7 +266,6 @@ function searchRows(rows, queryStr, now, limit) {
} }
results.sort(function(a, b) { results.sort(function(a, b) {
if (b.score !== a.score) return b.score - a.score
return (b.row.ts || 0) - (a.row.ts || 0) return (b.row.ts || 0) - (a.row.ts || 0)
}) })
+283 -61
View File
@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import Quickshell.Io
import qs.Commons import qs.Commons
import qs.Ui import qs.Ui
import "Classify.js" as Classify import "Classify.js" as Classify
@@ -14,6 +15,57 @@ Item {
property string derived: result ? result.row.type : "" property string derived: result ? result.row.type : ""
// Wired by the picker: opens the current result (browser for links). // Wired by the picker: opens the current result (browser for links).
property var openAction: function() {} property var openAction: function() {}
// Wired by the picker: copies a string (OCR text, QR payload) to the clipboard.
property var copyTextAction: function(text) {}
property string pluginDir: ""
property int fileIndex: 0
readonly property var filePaths: entry && entry.type === "files" ? entry.paths || [] : []
readonly property string selectedFile: filePaths.length ? filePaths[Math.min(fileIndex, filePaths.length - 1)] : ""
property var fileInfo: ({})
property int fileRequest: 0
onFilePathsChanged: fileIndex = 0
onSelectedFileChanged: {
fileRequest++
fileInfo = ({})
fileProbe.running = false
fileDelay.restart()
}
Timer {
id: fileDelay
interval: 120
onTriggered: {
if (!root.selectedFile || !root.pluginDir) return
fileProbe.command = ["python3", root.pluginDir + "/scripts/file-preview.py", String(root.fileRequest), root.selectedFile]
fileProbe.running = true
}
}
Process {
id: fileProbe
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
try {
var data = JSON.parse(text)
if (String(data.request) === String(root.fileRequest)) root.fileInfo = data.file
} catch (e) {}
}
}
}
function fileDetails() {
var f = fileInfo
var parts = []
if (f.kind) parts.push(f.kind)
if (f.bytes !== undefined && f.kind !== "Folder") parts.push(Classify.formatBytes(f.bytes))
if (f.width && f.height) parts.push(f.width + "×" + f.height)
if (f.duration) {
var seconds = Math.floor(f.duration)
parts.push(Math.floor(seconds / 60) + ":" + (seconds % 60 < 10 ? "0" : "") + seconds % 60)
}
if (f.audio) parts.push(f.audio)
if (f.modified) parts.push("Modified " + new Date(f.modified * 1000).toLocaleString())
return parts.join(" · ")
}
readonly property string font_: Style.font.menuFamily readonly property string font_: Style.font.menuFamily
readonly property color fg: Color.menu.text readonly property color fg: Color.menu.text
@@ -21,22 +73,19 @@ Item {
readonly property color chipBg: Util.alpha(fg, 0.07) readonly property color chipBg: Util.alpha(fg, 0.07)
readonly property color lineColor: Util.alpha(fg, 0.16) readonly property color lineColor: Util.alpha(fg, 0.16)
property string bodyText: "" // Textual types render in the scrollable body; every other type has its
// own block below. Derived as bindings (not set from onResultChanged) so
onResultChanged: prepare() // the body text and the type can never disagree mid-update, which used to
// leave a stale text body visible underneath an image preview.
function prepare() { readonly property bool textual: derived === "text" || derived === "code"
bodyText = "" || derived === "email" || derived === "number"
if (!entry) return || derived === "json" || derived === "html"
var t = derived readonly property string bodyText: {
if (t === "json") { if (!entry || !textual) return ""
var pretty = Classify.prettyJson(String(entry.text || ""), 200000) var raw = String(entry.text || "")
bodyText = pretty || String(entry.text || "") if (derived === "json") return Classify.prettyJson(raw, 200000) || raw
} else if (t === "html") { if (derived === "html") return Classify.stripHtml(raw) || raw
bodyText = Classify.stripHtml(String(entry.text || "")) || String(entry.text || "") return raw
} else if (t === "text" || t === "code" || t === "email" || t === "number") {
bodyText = String(entry.text || "")
}
} }
function rawSafe() { function rawSafe() {
@@ -80,7 +129,7 @@ Item {
chips.push(Classify.plural(st.words, "word")) chips.push(Classify.plural(st.words, "word"))
chips.push(Classify.plural(st.lines, "line")) chips.push(Classify.plural(st.lines, "line"))
} }
if (r.bytes > 0) chips.push(Classify.formatBytes(r.bytes)) if (r.bytes > 0 && e.type !== "files") chips.push(Classify.formatBytes(r.bytes))
if (e.type === "image" && e.w && e.h) chips.push(e.w + "×" + e.h) if (e.type === "image" && e.w && e.h) chips.push(e.w + "×" + e.h)
if (e.type === "image") chips.push(e.mime || "image") if (e.type === "image") chips.push(e.mime || "image")
if (e.pinned) chips.push("★ pinned") if (e.pinned) chips.push("★ pinned")
@@ -197,7 +246,7 @@ Item {
anchors.right: parent.right anchors.right: parent.right
anchors.topMargin: Style.space(10) anchors.topMargin: Style.space(10)
anchors.bottomMargin: Style.space(10) anchors.bottomMargin: Style.space(10)
visible: root.bodyText !== "" visible: root.textual && root.bodyText !== ""
clip: true clip: true
contentWidth: width contentWidth: width
contentHeight: bodyEdit.implicitHeight contentHeight: bodyEdit.implicitHeight
@@ -396,6 +445,30 @@ Item {
font.bold: true font.bold: true
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
Rectangle {
radius: height / 2
color: Util.alpha(Color.accent, 0.15)
width: qrCopyLabel.implicitWidth + Style.space(16)
height: Style.space(20)
anchors.verticalCenter: parent.verticalCenter
Text {
id: qrCopyLabel
anchors.centerIn: parent
text: "󰆏 Copy"
color: Color.accent
font.family: root.font_
font.pixelSize: Style.font.caption
font.bold: true
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.copyTextAction(root.entry ? String(root.entry.qr || "") : "")
}
}
} }
TextEdit { TextEdit {
@@ -458,9 +531,13 @@ Item {
id: ocrPanel id: ocrPanel
visible: !!(root.entry && root.entry.ocr) visible: !!(root.entry && root.entry.ocr)
width: parent.width width: parent.width
height: Math.min(Style.space(150), ocrContent.height + Style.space(12)) // Never taller than the cap: the body scrolls instead of overflowing
// the panel (which used to spill over the image below it).
readonly property int maxHeight: Math.max(Style.space(80), Math.floor(parent.height * 0.4))
height: Math.min(maxHeight, ocrHeader.height + Style.space(4) + ocrText.implicitHeight + Style.space(12))
radius: Style.cornerRadius radius: Style.cornerRadius
color: root.chipBg color: root.chipBg
clip: true
Column { Column {
id: ocrContent id: ocrContent
@@ -472,20 +549,62 @@ Item {
anchors.rightMargin: Style.space(6) anchors.rightMargin: Style.space(6)
spacing: Style.space(4) spacing: Style.space(4)
Row {
id: ocrHeader
width: parent.width
spacing: Style.space(8)
Text { Text {
text: "󰐦 Recognized text (OCR)" text: "󰐦 Recognized text (OCR)"
color: Color.accent color: Color.accent
font.family: root.font_ font.family: root.font_
font.pixelSize: Style.font.caption font.pixelSize: Style.font.caption
font.bold: true font.bold: true
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
radius: height / 2
color: Util.alpha(Color.accent, 0.15)
width: ocrCopyLabel.implicitWidth + Style.space(16)
height: Style.space(20)
anchors.verticalCenter: parent.verticalCenter
Text {
id: ocrCopyLabel
anchors.centerIn: parent
text: "󰆏 Copy text"
color: Color.accent
font.family: root.font_
font.pixelSize: Style.font.caption
font.bold: true
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.copyTextAction(root.entry ? String(root.entry.ocr || "") : "")
}
}
} }
Flickable { Flickable {
id: ocrFlick
width: parent.width width: parent.width
height: ocrText.implicitHeight height: ocrPanel.height - ocrHeader.height - Style.space(4) - Style.space(12)
clip: true clip: true
contentWidth: width contentWidth: width
contentHeight: ocrText.implicitHeight contentHeight: ocrText.implicitHeight
boundsBehavior: Flickable.StopAtBounds
WheelHandler {
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: function(ev) {
if (ev.angleDelta.y < 0) ocrFlick.flick(0, -240)
else ocrFlick.flick(0, 240)
ev.accepted = true
}
}
TextEdit { TextEdit {
id: ocrText id: ocrText
@@ -556,8 +675,10 @@ Item {
} }
} }
// files body // File content is bounded by a scrollable viewport, including long paths
Column { // and text snippets. Selecting another file loads its details on demand.
Flickable {
id: filesBody
anchors.top: divider.bottom anchors.top: divider.bottom
anchors.bottom: metaRow.top anchors.bottom: metaRow.top
anchors.left: parent.left anchors.left: parent.left
@@ -565,57 +686,158 @@ Item {
anchors.topMargin: Style.space(10) anchors.topMargin: Style.space(10)
anchors.bottomMargin: Style.space(10) anchors.bottomMargin: Style.space(10)
visible: root.derived === "files" visible: root.derived === "files"
spacing: Style.space(6)
clip: true clip: true
contentWidth: width
Repeater { contentHeight: fileContent.height
model: { boundsBehavior: Flickable.StopAtBounds
if (!root.entry || root.entry.type !== "files") return [] Connections {
return (root.entry.paths || []).slice(0, 10) target: root
function onSelectedFileChanged() { filesBody.contentY = 0 }
} }
WheelHandler {
delegate: Row { acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
required property var modelData onWheel: function(ev) {
width: parent ? parent.width : 0 filesBody.flick(0, ev.angleDelta.y < 0 ? -240 : 240)
ev.accepted = true
}
}
Column {
id: fileContent
width: filesBody.width
spacing: Style.space(8) spacing: Style.space(8)
Row {
width: parent.width
spacing: Style.space(8)
visible: root.filePaths.length > 1
Repeater {
model: [" Previous", "Next "]
delegate: Rectangle {
required property int index
required property string modelData
width: navLabel.implicitWidth + Style.space(16)
height: Style.space(24)
color: root.chipBg
radius: Style.cornerRadius
Text { Text {
text: "󰈚" id: navLabel
color: Color.accent anchors.centerIn: parent
text: modelData
color: root.fg
font.family: root.font_ font.family: root.font_
font.pixelSize: Style.font.body font.pixelSize: Style.font.caption
}
MouseArea {
anchors.fill: parent
onClicked: root.fileIndex = (root.fileIndex + (index === 0 ? -1 : 1) + root.filePaths.length) % root.filePaths.length
}
}
} }
Text { Text {
text: Classify.fileBase(modelData) text: (root.fileIndex + 1) + " / " + root.filePaths.length
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
}
Text {
width: parent.width
text: Classify.fileBase(root.selectedFile)
textFormat: Text.PlainText
wrapMode: Text.WrapAnywhere
color: root.fg color: root.fg
font.family: root.font_ font.family: root.font_
font.pixelSize: Style.font.body font.pixelSize: Style.font.body
elide: Text.ElideMiddle font.bold: true
width: parent.width - Style.space(24)
maximumLineCount: 1
} }
} Rectangle {
} id: thumbnailPanel
readonly property bool loading: fileDelay.running || fileProbe.running || fileThumbnail.status === Image.Loading
Text {
visible: !!(root.entry && root.entry.paths && root.entry.paths.length > 10)
text: root.entry && root.entry.paths ? "… and " + (root.entry.paths.length - 10) + " more" : ""
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
Text {
text: root.entry && root.entry.paths && root.entry.paths.length > 0
? "in " + Classify.fileDir(root.entry.paths[0])
: ""
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
elide: Text.ElideMiddle
width: parent.width width: parent.width
maximumLineCount: 1 height: visible ? Math.min(Style.space(240), filesBody.height * 0.55) : 0
visible: loading || !!root.fileInfo.thumbnail
color: root.chipBg
radius: Style.cornerRadius
clip: true
Image {
id: fileThumbnail
anchors.fill: parent
visible: status === Image.Ready && !thumbnailPanel.loading
source: root.fileInfo.thumbnail || ""
fillMode: Image.PreserveAspectFit
asynchronous: true
sourceSize.width: 640
sourceSize.height: 360
}
Column {
anchors.centerIn: parent
spacing: Style.space(8)
visible: thumbnailPanel.loading
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "◌"
color: Color.accent
font.pixelSize: Style.space(28)
NumberAnimation on rotation {
from: 0
to: 360
duration: 1000
loops: Animation.Infinite
running: thumbnailPanel.loading && filesBody.visible
}
}
Text {
text: "Loading preview…"
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
}
Text {
anchors.centerIn: parent
visible: !thumbnailPanel.loading && fileThumbnail.status === Image.Error
text: "Thumbnail unavailable"
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
}
Text {
width: parent.width
text: root.fileInfo.error || root.fileDetails() || (thumbnailPanel.loading ? "Loading file information…" : "File information unavailable")
textFormat: Text.PlainText
wrapMode: Text.WrapAnywhere
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
Text {
width: parent.width
text: root.selectedFile
textFormat: Text.PlainText
wrapMode: Text.WrapAnywhere
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
TextEdit {
width: parent.width
visible: text.length > 0
text: root.fileInfo.text || root.fileInfo.details || ""
textFormat: TextEdit.PlainText
readOnly: true
activeFocusOnPress: false
wrapMode: TextEdit.WrapAnywhere
color: root.fg
font.family: root.font_
font.pixelSize: Style.font.body
}
Text {
visible: !!root.fileInfo.truncated
text: "Preview limited to the first 8 KiB"
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
} }
} }
+53 -6
View File
@@ -11,8 +11,8 @@ full theme integration.
## Highlights ## Highlights
- **Fuzzy search over everything** — fzf-style scoring across content, source - **Fuzzy search over everything** — fzf-style matching across content, source
app, and type, boosted by recency, pins, and usage. Query tokens: app, and type. The list is always newest-first; search only filters it. Query tokens:
- `type:image|link|text|files|code|json|color|email|html|number` (prefix match) - `type:image|link|text|files|code|json|color|email|html|number` (prefix match)
- `app:firefox` — fuzzy match on the source app - `app:firefox` — fuzzy match on the source app
- `is:pinned`, `today`, `yesterday`, `week`, `<2h`, `>30s`, `<3d` - `is:pinned`, `today`, `yesterday`, `week`, `<2h`, `>30s`, `<3d`
@@ -61,6 +61,35 @@ That's the whole install — `omarchy plugin add` clones the repo, validates the
manifest, and enables it. It replaces the built-in `omarchy.clipboard` manifest, and enables it. It replaces the built-in `omarchy.clipboard`
(restore it later with `omarchy plugin disable alanfortlink.clipboard`). (restore it later with `omarchy plugin disable alanfortlink.clipboard`).
### File previews
Copied files show their name, type, size, modification time, and path. Image
and video files show a thumbnail and resolution; videos and audio show duration,
and audio also shows codec, sample rate, and channels. PDFs show a first-page
thumbnail and page information. Text and code files show the first 8 KiB.
Folders and other files retain basic information. Use Previous/Next to inspect
multiple copied files. Details load on selection, including for existing history.
Media previews use optional `ffmpeg` (`ffprobe` included); PDF previews use
optional `poppler` (`pdfinfo` and `pdftoppm`). Without them, basic file information
still works. Preview commands have time and output limits, and media thumbnails
are skipped above 40 megapixels. Missing or moved files show an error.
### Dependencies
All are regular Arch packages; nothing is downloaded or run at install time.
Required: `wl-clipboard`, `jq`, `python3`, `wtype` (paste into the focused
window). Optional: `zbar` (QR decoding) and `tesseract` (OCR search) — without
them the picker still works and shows the `omarchy pkg add …` command to add them.
No sudo or pkexec is required by the plugin itself.
The clipboard owner is treated as untrusted. Payloads are streamed through a
capped reader and dropped when they exceed 32 MiB (images) or 4 MiB (text), or
take longer than 5 s to deliver; QR decoding and OCR only run on images under
40 megapixels, as read from the file header without decoding. Override with
`CLIPBOARD_MAX_IMAGE_BYTES`, `CLIPBOARD_MAX_TEXT_BYTES`, and
`CLIPBOARD_MAX_PARSE_PIXELS` in the shell's environment.
Omarchy's default `Super+Ctrl+V` routes to it via the clone mechanism. An Omarchy's default `Super+Ctrl+V` routes to it via the clone mechanism. An
existing `Super+Shift+V` binding targeting `omarchy.clipboard` routes to it too. existing `Super+Shift+V` binding targeting `omarchy.clipboard` routes to it too.
For a custom binding, edit the **live** config — For a custom binding, edit the **live** config —
@@ -68,14 +97,31 @@ For a custom binding, edit the **live** config —
but is not sourced): but is not sourced):
```lua ```lua
o.bind("SUPER + SHIFT + V", "Clipboard manager (clipboard-history)", o.bind("SUPER + SHIFT + V", "Clipboard manager",
"omarchy-shell alanfortlink.clipboard toggle") "omarchy-shell shell toggle omarchy.clipboard")
``` ```
(If your config is still legacy `bindings.conf`: `bindd = ALT SHIFT, V, …`.) (If your config is still legacy `bindings.conf`: `bindd = ALT SHIFT, V, …`.)
Updating: `omarchy plugin update alanfortlink.clipboard` · Uninstall: `omarchy plugin remove alanfortlink.clipboard` Updating: `omarchy plugin update alanfortlink.clipboard` · Uninstall: `omarchy plugin remove alanfortlink.clipboard`
### Upgrading from a pre-1.0 install
If you installed an earlier build (plugin id `tank.clipboard`, or a symlinked
checkout via `install.sh`), remove it, reinstall from git, and repair any direct
shortcut bindings so they target Omarchy's clone-aware `omarchy.clipboard` id:
```bash
omarchy plugin remove tank.clipboard --yes # or: alanfortlink.clipboard
omarchy plugin add https://github.com/alanfortlink/clipboard-history.git --enable --yes
bash ~/.config/omarchy/plugins/alanfortlink.clipboard/scripts/configure-bindings.sh
omarchy restart shell
```
History data is preserved. `configure-bindings.sh` edits `bindings.lua` (or
`bindings.conf`) and leaves a timestamped `.bak` copy next to it; it is only
ever run when you invoke it explicitly.
## Keys ## Keys
| Key | Action | | Key | Action |
@@ -84,6 +130,7 @@ Updating: `omarchy plugin update alanfortlink.clipboard` · Uninstall: `omarchy
| `Enter` | copy to clipboard and paste into the focused window | | `Enter` | copy to clipboard and paste into the focused window |
| `Shift+Enter` | copy only | | `Shift+Enter` | copy only |
| `Ctrl+O` | open a pure link in the browser, an image in the editor, or a file externally. Text entries stay in the picker; use `Return` to paste them. QR images open their **decoded link** in the browser; the preview pane also has clickable "Open link" chips for links and QR payloads | | `Ctrl+O` | open a pure link in the browser, an image in the editor, or a file externally. Text entries stay in the picker; use `Return` to paste them. QR images open their **decoded link** in the browser; the preview pane also has clickable "Open link" chips for links and QR payloads |
| `Ctrl+Shift+C` | copy the entry's text content as plain text: for images, the OCR text (or QR payload). The QR and OCR panels also have **Copy** chips |
| `Tab` | pin/unpin | | `Tab` | pin/unpin |
| `Ctrl+=` | pause/resume recording | | `Ctrl+=` | pause/resume recording |
| `Delete` | remove entry · `Shift+Delete` clear all (with confirm) | | `Delete` | remove entry · `Shift+Delete` clear all (with confirm) |
@@ -92,8 +139,8 @@ Updating: `omarchy plugin update alanfortlink.clipboard` · Uninstall: `omarchy
Pause/resume is also scriptable — useful for automation or a custom binding: Pause/resume is also scriptable — useful for automation or a custom binding:
```bash ```bash
omarchy-shell alanfortlink.clipboard pause '{"paused":"toggle"}' omarchy-shell shell call alanfortlink.clipboard pause '{"paused":"toggle"}'
omarchy-shell alanfortlink.clipboard isPaused omarchy-shell shell call alanfortlink.clipboard isPaused
``` ```
## Configuration ## Configuration
+172 -21
View File
@@ -11,12 +11,19 @@ the current clipboard. Emits exactly one JSON line per capture:
Sensitive clips (x-kde-passwordManagerHint / CLIPBOARD_STATE=sensitive) and Sensitive clips (x-kde-passwordManagerHint / CLIPBOARD_STATE=sensitive) and
binary payloads are skipped silently. binary payloads are skipped silently.
The clipboard owner is untrusted: every payload is read through a bounded
reader (byte cap + deadline) and QR/OCR only run on images whose header
dimensions are under a pixel cap, so a hostile or runaway source cannot make
the plugin buffer an unbounded payload or decode a decompression bomb.
""" """
import hashlib import hashlib
import json import json
import os import os
import select
import shutil import shutil
import struct
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -34,6 +41,24 @@ IMAGE_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp",
TEXT_TYPES = ["text/plain;charset=utf-8", "text/plain", "UTF8_STRING", "STRING", "TEXT", "COMPOUND_TEXT"] TEXT_TYPES = ["text/plain;charset=utf-8", "text/plain", "UTF8_STRING", "STRING", "TEXT", "COMPOUND_TEXT"]
def _env_int(name, default):
try:
return max(0, int(os.environ.get(name, "") or default))
except ValueError:
return default
# Payload limits. Anything larger is dropped (not truncated) so the history
# never holds a partial clip. Overridable per environment.
MAX_IMAGE_BYTES = _env_int("CLIPBOARD_MAX_IMAGE_BYTES", 32 * 1024 * 1024)
MAX_TEXT_BYTES = _env_int("CLIPBOARD_MAX_TEXT_BYTES", 4 * 1024 * 1024)
# QR decoding and OCR decode the full bitmap; skip them above this many
# pixels (the image is still recorded and previewed by Qt, which has its own
# allocation limits).
MAX_PARSE_PIXELS = _env_int("CLIPBOARD_MAX_PARSE_PIXELS", 40 * 1000 * 1000)
READ_TIMEOUT = 5.0
def run(args, timeout=5): def run(args, timeout=5):
try: try:
r = subprocess.run(args, capture_output=True, timeout=timeout) r = subprocess.run(args, capture_output=True, timeout=timeout)
@@ -42,6 +67,115 @@ def run(args, timeout=5):
return None return None
def read_bounded(args, limit, timeout=READ_TIMEOUT):
"""Run `args` and return its stdout, or None if it exits non-zero, writes
more than `limit` bytes, or does not finish within `timeout` seconds.
Output is streamed and the process is killed as soon as the cap is hit,
so memory use is bounded by `limit` regardless of what the source sends."""
try:
proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
except Exception:
return None
chunks = []
total = 0
ok = True
deadline = time.monotonic() + timeout
try:
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
ok = False
break
ready, _, _ = select.select([proc.stdout], [], [], remaining)
if not ready:
ok = False
break
chunk = os.read(proc.stdout.fileno(), 65536)
if not chunk:
break
total += len(chunk)
if total > limit:
ok = False
break
chunks.append(chunk)
except Exception:
ok = False
finally:
if not ok:
proc.kill()
try:
proc.wait(timeout=2)
except Exception:
proc.kill()
proc.stdout.close()
if not ok or proc.returncode != 0:
return None
return b"".join(chunks)
def read_clipboard(mime, limit):
return read_bounded(["wl-paste", "--type", mime, "--no-newline"], limit)
def image_size(data, mime):
"""(width, height) from the container header alone — no pixel decoding.
Returns None when the header is not understood."""
try:
if mime == "image/png" and data[:8] == b"\x89PNG\r\n\x1a\n" and data[12:16] == b"IHDR":
return struct.unpack(">II", data[16:24])
if mime == "image/gif" and data[:6] in (b"GIF87a", b"GIF89a"):
return struct.unpack("<HH", data[6:10])
if mime == "image/bmp" and data[:2] == b"BM":
w, h = struct.unpack("<ii", data[18:26])
return abs(w), abs(h)
if mime == "image/webp" and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
chunk = data[12:16]
if chunk == b"VP8X":
w = int.from_bytes(data[24:27], "little") + 1
h = int.from_bytes(data[27:30], "little") + 1
return w, h
if chunk == b"VP8L" and data[20] == 0x2F:
bits = int.from_bytes(data[21:25], "little")
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
if chunk == b"VP8 ":
w, h = struct.unpack("<HH", data[26:30])
return w & 0x3FFF, h & 0x3FFF
if mime == "image/jpeg" and data[:2] == b"\xff\xd8":
i = 2
n = len(data)
while i + 9 < n:
if data[i] != 0xFF:
i += 1
continue
marker = data[i + 1]
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
i += 2
continue
length = struct.unpack(">H", data[i + 2:i + 4])[0]
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
h, w = struct.unpack(">HH", data[i + 5:i + 9])
return w, h
i += 2 + length
if mime == "image/tiff" and data[:4] in (b"II*\x00", b"MM\x00*"):
end = "<" if data[:2] == b"II" else ">"
off = struct.unpack(end + "I", data[4:8])[0]
count = struct.unpack(end + "H", data[off:off + 2])[0]
w = h = None
for k in range(min(count, 64)):
e = off + 2 + k * 12
tag, typ = struct.unpack(end + "HH", data[e:e + 4])
val = struct.unpack(end + ("H" if typ == 3 else "I"), data[e + 8:e + 8 + (2 if typ == 3 else 4)])[0]
if tag == 256:
w = val
elif tag == 257:
h = val
if w and h:
return w, h
except Exception:
return None
return None
def list_types(): def list_types():
out = run(["wl-paste", "--list-types"]) out = run(["wl-paste", "--list-types"])
if not out: if not out:
@@ -86,22 +220,22 @@ def capture_image(types, app):
mime = next((m for m in IMAGE_MIMES if m in types), None) mime = next((m for m in IMAGE_MIMES if m in types), None)
if not mime: if not mime:
return return
data = run(["wl-paste", "--type", mime, "--no-newline"]) data = read_clipboard(mime, MAX_IMAGE_BYTES)
if not data: if not data:
return # Oversized, slow, or empty: skip the clip entirely (an image mime was
# offered, so we do not fall through to a text capture of it either).
return True
os.makedirs(IMAGE_DIR, exist_ok=True) os.makedirs(IMAGE_DIR, exist_ok=True)
digest = hashlib.sha256(data).hexdigest() digest = hashlib.sha256(data).hexdigest()
path = os.path.join(IMAGE_DIR, f"{digest}.{IMAGE_EXT[mime]}") path = os.path.join(IMAGE_DIR, f"{digest}.{IMAGE_EXT[mime]}")
entry = {"type": "image", "mime": mime, "path": path, "bytes": len(data), "app": app} entry = {"type": "image", "mime": mime, "path": path, "bytes": len(data), "app": app}
# Dimensions via Pillow when available; harmless without it. # Dimensions from the header only; the bitmap is never decoded here.
try: size = image_size(data, mime)
from PIL import Image # noqa: PLC0415 if size:
import io # noqa: PLC0415 entry["w"], entry["h"] = int(size[0]), int(size[1])
with Image.open(io.BytesIO(data)) as im: # Unknown or huge dimensions → no QR/OCR pass (both decode the full image).
entry["w"], entry["h"] = im.size parse_ok = bool(size) and size[0] * size[1] <= MAX_PARSE_PIXELS
except Exception:
pass
if not os.path.exists(path): if not os.path.exists(path):
fd, tmp = tempfile.mkstemp(dir=IMAGE_DIR) fd, tmp = tempfile.mkstemp(dir=IMAGE_DIR)
@@ -116,11 +250,11 @@ def capture_image(types, app):
pass pass
return return
qr = decode_qr(path) if os.environ.get("CLIPBOARD_QR", "1") != "0" else None qr = decode_qr(path) if parse_ok and os.environ.get("CLIPBOARD_QR", "1") != "0" else None
if qr: if qr:
entry["qr"] = qr entry["qr"] = qr
ocr = decode_ocr(path, os.environ.get("CLIPBOARD_OCR_LANG", "eng")) ocr = decode_ocr(path, os.environ.get("CLIPBOARD_OCR_LANG", "eng")) if parse_ok else None
if ocr: if ocr:
entry["ocr"] = ocr entry["ocr"] = ocr
@@ -175,7 +309,7 @@ def decode_ocr(path, lang):
def capture_uri_list(types, app): def capture_uri_list(types, app):
if "text/uri-list" not in types: if "text/uri-list" not in types:
return False return False
data = run(["wl-paste", "--type", "text/uri-list", "--no-newline"]) data = read_clipboard("text/uri-list", MAX_TEXT_BYTES)
if not data: if not data:
return False return False
text = decode_text(data) text = decode_text(data)
@@ -200,7 +334,7 @@ def capture_text(types, app):
mime = next((m for m in TEXT_TYPES if m in types), None) mime = next((m for m in TEXT_TYPES if m in types), None)
if not mime: if not mime:
return return
data = run(["wl-paste", "--type", mime, "--no-newline"]) data = read_clipboard(mime, MAX_TEXT_BYTES)
if not data: if not data:
return return
text = decode_text(data) text = decode_text(data)
@@ -209,14 +343,31 @@ def capture_text(types, app):
emit({"type": "text", "text": text, "bytes": len(data), "app": app}) emit({"type": "text", "text": text, "bytes": len(data), "app": app})
def main(): def drain_stdin(timeout=READ_TIMEOUT):
# The watcher pipes the clipboard payload to us; wl-clipboard blocks on """Discard the watcher's payload in fixed chunks, with a total deadline.
# writes if we close the pipe early, so drain it instead (we still probe
# types ourselves below). False makes main exit without probing; process exit closes the pipe so
a stalled or endless producer cannot keep this helper alive indefinitely.
"""
deadline = time.monotonic() + timeout
try: try:
sys.stdin.buffer.read() fd = sys.stdin.fileno()
except Exception: while True:
pass remaining = deadline - time.monotonic()
if remaining <= 0:
return False
ready, _, _ = select.select([fd], [], [], remaining)
if not ready:
return False
if not os.read(fd, 65536):
return True
except (OSError, ValueError):
return False
def main():
if not drain_stdin():
return
types = list_types() types = list_types()
if not types: if not types:
return return
+5 -47
View File
@@ -5,7 +5,7 @@
# - rescans + enables it (shell.json gets plugins[] entry; the built-in # - rescans + enables it (shell.json gets plugins[] entry; the built-in
# omarchy.clipboard is recorded in disabledPlugins[] and routed here) # omarchy.clipboard is recorded in disabledPlugins[] and routed here)
# - checks and installs dependencies transparently (QR/OCR degrade without them) # - checks and installs dependencies transparently (QR/OCR degrade without them)
# - binds SUPER+SHIFT+V and ALT+SHIFT+V directly to this picker (with backup) # - binds SUPER+SHIFT+V and ALT+SHIFT+V to the clone-aware source id (with backup)
set -euo pipefail set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -38,7 +38,8 @@ mkdir -p "$PLUGINS_DIR"
ln -sTfn "$PLUGIN_DIR" "$PLUGINS_DIR/$PLUGIN_ID" ln -sTfn "$PLUGIN_DIR" "$PLUGINS_DIR/$PLUGIN_ID"
# On other machines, install directly from git instead: # On other machines, install directly from git instead:
# omarchy plugin add <this-repo-git-url> --enable # omarchy plugin add <this-repo-git-url> --enable
chmod +x "$PLUGIN_DIR"/capture.py "$PLUGIN_DIR"/paste-entry.sh "$PLUGIN_DIR"/open-entry.sh chmod +x "$PLUGIN_DIR"/capture.py "$PLUGIN_DIR"/paste-entry.sh "$PLUGIN_DIR"/open-entry.sh \
"$PLUGIN_DIR"/scripts/configure-bindings.sh
echo "==> Rescanning shell plugins" echo "==> Rescanning shell plugins"
omarchy-shell shell rescanPlugins >/dev/null omarchy-shell shell rescanPlugins >/dev/null
@@ -58,51 +59,8 @@ fi
echo "==> Enabling $PLUGIN_ID (replaces built-in omarchy.clipboard)" echo "==> Enabling $PLUGIN_ID (replaces built-in omarchy.clipboard)"
omarchy plugin enable "$PLUGIN_ID" omarchy plugin enable "$PLUGIN_ID"
echo "==> Binding SUPER+SHIFT+V and ALT+SHIFT+V directly to $PLUGIN_ID" echo "==> Binding SUPER+SHIFT+V and ALT+SHIFT+V to the clipboard source"
# Do not route through `shell toggle`: plugin rescans can leave its panel Loader "$PLUGIN_DIR/scripts/configure-bindings.sh"
# stale. The plugin-owned IPC target always controls the mapped picker window.
rebound=0
if [[ -f $HOME/.config/hypr/bindings.lua ]]; then
LUA="$HOME/.config/hypr/bindings.lua"
tmp=$(mktemp)
python3 - "$LUA" >"$tmp" <<'PY'
import re, sys
text = open(sys.argv[1]).read()
command = "omarchy-shell alanfortlink.clipboard toggle"
for chord in ("SUPER + SHIFT + V", "ALT + SHIFT + V"):
line = f'o.bind("{chord}", "Clipboard manager (clipboard-history)", "{command}")'
pattern = re.compile(r'^\s*o\.bind\("' + re.escape(chord) + r'".*$', re.M)
if pattern.search(text):
text = pattern.sub(line, text, count=1)
else:
text = text.rstrip() + "\n" + line + "\n"
sys.stdout.write(text)
PY
if ! cmp -s "$LUA" "$tmp"; then
cp "$LUA" "$LUA.bak.$(date +%s)"
mv "$tmp" "$LUA"
echo " updated bindings.lua (backup created)"
else
rm -f "$tmp"
echo " bindings.lua already correct"
fi
rebound=1
elif [[ -f $HOME/.config/hypr/bindings.conf ]]; then
CONF="$HOME/.config/hypr/bindings.conf"
cp "$CONF" "$CONF.bak.$(date +%s)"
sed -i '/^bindd = \(SUPER\|ALT\) SHIFT, V, /d' "$CONF"
printf '%s\n' \
'bindd = SUPER SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell alanfortlink.clipboard toggle' \
'bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell alanfortlink.clipboard toggle' >>"$CONF"
echo " updated bindings.conf (backup created)"
rebound=1
fi
if (( rebound )); then
hyprctl reload >/dev/null
else
echo "ERROR: no live Hyprland bindings file found" >&2
exit 1
fi
echo echo
echo "Done. Press SUPER+SHIFT+V (or ALT+SHIFT+V) to open the clipboard picker." echo "Done. Press SUPER+SHIFT+V (or ALT+SHIFT+V) to open the clipboard picker."
+1 -1
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "alanfortlink.clipboard", "id": "alanfortlink.clipboard",
"name": "Clipboard History", "name": "Clipboard History",
"version": "1.0.6", "version": "1.0.7",
"author": "alanfortlink", "author": "alanfortlink",
"description": "Raycast-style clipboard history: fuzzy search over content, type, app and date, with rich per-type previews", "description": "Raycast-style clipboard history: fuzzy search over content, type, app and date, with rich per-type previews",
"kinds": [ "kinds": [
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

+11 -1
View File
@@ -11,7 +11,9 @@ import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import capture # noqa: E402 (image_size / MAX_PARSE_PIXELS shared with the daemon)
STATE = os.path.join( STATE = os.path.join(
os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")), os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")),
@@ -56,6 +58,14 @@ def main():
continue continue
if not os.path.exists(entry["path"]): if not os.path.exists(entry["path"]):
continue continue
# Same guard as the capture daemon: never decode an image whose header
# dimensions are unknown or above the pixel cap.
with open(entry["path"], "rb") as f:
head = f.read(65536)
size = capture.image_size(head, entry.get("mime") or "image/png")
if not size or size[0] * size[1] > capture.MAX_PARSE_PIXELS:
print(f" - {entry['id']}: skipped (size {size or 'unknown'})")
continue
text = ocr(entry["path"], args.lang) text = ocr(entry["path"], args.lang)
if text: if text:
entry["ocr"] = text entry["ocr"] = text
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# Configure clipboard shortcuts through Omarchy's clone-aware source id.
# The same bindings open alanfortlink.clipboard while installed and the
# built-in omarchy.clipboard after this plugin is disabled or removed.
set -euo pipefail
HYPR_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/hypr"
LUA="$HYPR_DIR/bindings.lua"
CONF="$HYPR_DIR/bindings.conf"
STAMP=$(date +%s)
changed=0
if [[ -f $LUA ]]; then
tmp=$(mktemp)
trap 'rm -f "${tmp:-}"' EXIT
python3 - "$LUA" >"$tmp" <<'PY'
import re
import sys
path = sys.argv[1]
text = open(path, encoding="utf-8").read()
command = "omarchy-shell shell toggle omarchy.clipboard"
for chord in ("SUPER + SHIFT + V", "ALT + SHIFT + V"):
line = f'o.bind("{chord}", "Clipboard manager", "{command}")'
pattern = re.compile(r'^\s*o\.bind\("' + re.escape(chord) + r'".*$', re.MULTILINE)
if pattern.search(text):
text = pattern.sub(line, text, count=1)
else:
text = text.rstrip() + "\n" + line + "\n"
sys.stdout.write(text)
PY
if ! cmp -s "$LUA" "$tmp"; then
cp "$LUA" "$LUA.bak.clipboard.$STAMP"
mv "$tmp" "$LUA"
changed=1
echo "Updated $LUA (backup: $LUA.bak.clipboard.$STAMP)"
else
echo "$LUA already uses clone-aware clipboard bindings"
fi
elif [[ -f $CONF ]]; then
tmp=$(mktemp)
trap 'rm -f "${tmp:-}"' EXIT
grep -vE '^bindd = (SUPER|ALT) SHIFT, V, ' "$CONF" >"$tmp" || true
printf '%s\n' \
'bindd = SUPER SHIFT, V, Clipboard manager, exec, omarchy-shell shell toggle omarchy.clipboard' \
'bindd = ALT SHIFT, V, Clipboard manager, exec, omarchy-shell shell toggle omarchy.clipboard' >>"$tmp"
if ! cmp -s "$CONF" "$tmp"; then
cp "$CONF" "$CONF.bak.clipboard.$STAMP"
mv "$tmp" "$CONF"
changed=1
echo "Updated $CONF (backup: $CONF.bak.clipboard.$STAMP)"
else
echo "$CONF already uses clone-aware clipboard bindings"
fi
else
echo "configure-bindings: no bindings.lua or bindings.conf found in $HYPR_DIR" >&2
exit 1
fi
if (( changed )); then
hyprctl reload >/dev/null
fi
errors=$(hyprctl configerrors)
if [[ -n $errors ]]; then
printf '%s\n' "$errors" >&2
exit 1
fi
echo "Clipboard shortcuts ready: SUPER+SHIFT+V and ALT+SHIFT+V"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Bounded, on-demand metadata for one local copied file; never modifies it."""
import base64
import json
import mimetypes
import os
from pathlib import Path
import stat
import sys
import time
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from capture import read_bounded
def preview(path):
result = {'path': path}
deadline = time.monotonic() + 6
def run(args, limit=128 * 1024):
remaining = deadline - time.monotonic()
return read_bounded(args, limit, timeout=min(2, remaining)) if remaining > 0 else None
try:
info = os.stat(path)
result.update(bytes=info.st_size, modified=int(info.st_mtime))
if stat.S_ISDIR(info.st_mode):
result['kind'] = 'Folder'
return result
if not stat.S_ISREG(info.st_mode):
result['kind'] = 'Special file'
return result
mime = mimetypes.guess_type(path)[0] or 'application/octet-stream'
result['mime'] = mime
result['kind'] = mime
# Do not let a media playlist cause ffmpeg to open referenced resources.
media = Path(path).suffix.lower() in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.avif', '.heic', '.mp4', '.mkv', '.webm', '.mov', '.avi', '.m4v', '.mp3', '.flac', '.wav', '.ogg', '.opus', '.m4a', '.aac'}
if media:
options = ['-v', 'error', '-max_alloc', '67108864', '-protocol_whitelist', 'file', '-probesize', '1048576', '-analyzeduration', '1000000']
data = run(['ffprobe'] + options + ['-show_entries', 'format=duration:stream=codec_type,codec_name,width,height,sample_rate,channels', '-of', 'json', path])
if data:
parsed = json.loads(data)
duration = float(parsed.get('format', {}).get('duration', 0))
if 0 < duration < 1e9:
result['duration'] = duration
streams = parsed.get('streams', [])
visual = next((s for s in streams if s.get('codec_type') == 'video'), {})
audio = next((s for s in streams if s.get('codec_type') == 'audio'), {})
for key in ('width', 'height'):
if visual.get(key):
result[key] = int(visual[key])
if audio:
result['audio'] = ' · '.join(str(audio[k]) + suffix for k, suffix in [('codec_name', ''), ('sample_rate', ' Hz'), ('channels', ' channels')] if audio.get(k))
if 0 < result.get('width', 0) * result.get('height', 0) <= 40000000:
thumb = run(['ffmpeg'] + options + ['-nostdin', '-threads', '1', '-i', path, '-frames:v', '1', '-vf', 'scale=640:360:force_original_aspect_ratio=decrease', '-threads', '1', '-f', 'image2pipe', '-c:v', 'mjpeg', '-protocol_whitelist', 'pipe', 'pipe:1'], 512 * 1024)
if thumb:
result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii')
elif mime == 'application/pdf':
thumb = run(['pdftoppm', '-f', '1', '-singlefile', '-scale-to', '640', '-jpeg', path], 512 * 1024)
if thumb:
result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii')
data = run(['pdfinfo', path])
if data:
result['details'] = '\n'.join(line.strip() for line in data.decode('utf-8', 'replace').splitlines() if line.startswith(('Pages:', 'Page size:', 'Title:', 'Author:')))
elif mime.startswith('text/') or Path(path).suffix.lower() in {'.json', '.yaml', '.yml', '.toml', '.md', '.py', '.js', '.ts', '.qml', '.sh', '.rs', '.go', '.log', '.csv'}:
# Nonblocking open avoids hanging on a file replaced with a FIFO.
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
with os.fdopen(fd, 'rb') as source:
if stat.S_ISREG(os.fstat(source.fileno()).st_mode):
data = source.read(8193)
if b'\0' not in data:
result['text'] = data[:8192].decode('utf-8', 'replace')
result['truncated'] = len(data) > 8192
except (OSError, ValueError, TypeError) as error:
result['error'] = str(error)
return result
if __name__ == '__main__':
print(json.dumps({'request': sys.argv[1], 'file': preview(os.path.abspath(sys.argv[2]))}))
+2 -1
View File
@@ -1,5 +1,6 @@
#!/bin/bash #!/bin/bash
# Run all plugin logic tests. Requires node. # Run all plugin logic tests. Requires node and python3.
set -euo pipefail set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.." cd "$(dirname "${BASH_SOURCE[0]}")/.."
python3 -m unittest discover -s tests -p 'test_*.py'
exec node --test 'tests/*.mjs' exec node --test 'tests/*.mjs'
+21 -4
View File
@@ -79,6 +79,21 @@ test("searchRows empty query sorts by recency", () => {
assert.equal(res[0].row.content, "new") assert.equal(res[0].row.content, "new")
}) })
test("searchRows: always newest first, pins and pastes do not reorder", () => {
const now = 1000000
const rows = [
{ entry: {}, content: "pasted twice, 5 min ago", app: "", type: "text", ts: now - 300, pinned: false, uses: 2, bytes: 3 },
{ entry: {}, content: "just copied", app: "", type: "text", ts: now - 5, pinned: false, uses: 0, bytes: 3 },
{ entry: { pinned: true }, content: "pinned, old", app: "", type: "text", ts: now - 86400 * 3, pinned: true, uses: 0, bytes: 3 }
]
const res = Fuzzy.searchRows(rows, "", now, 10)
assert.deepEqual(Array.from(res, r => r.row.content), ["just copied", "pasted twice, 5 min ago", "pinned, old"])
const typed = Fuzzy.searchRows(rows, "type:text <1h", now, 10)
assert.deepEqual(Array.from(typed, r => r.row.content), ["just copied", "pasted twice, 5 min ago"])
const searched = Fuzzy.searchRows(rows, "past", now, 10)
assert.deepEqual(Array.from(searched, r => r.row.content), ["pasted twice, 5 min ago"])
})
test("searchRows fuzzy ranks match above recency when strong", () => { test("searchRows fuzzy ranks match above recency when strong", () => {
const now = 1000000 const now = 1000000
const rows = [ const rows = [
@@ -122,14 +137,16 @@ test("searchRows matches app field", () => {
assert.equal(res[0].row.app, "firefox") assert.equal(res[0].row.app, "firefox")
}) })
test("searchRows pinned boost wins", () => { test("searchRows is:pinned filters without reordering", () => {
const now = 1000000 const now = 1000000
const rows = [ const rows = [
{ entry: { pinned: true }, content: "zzz pinned weak", app: "", type: "text", ts: now - 86400 * 10, pinned: true, uses: 0, bytes: 3 }, { entry: { pinned: true }, content: "zzz pinned old", app: "", type: "text", ts: now - 86400 * 10, pinned: true, uses: 0, bytes: 3 },
{ entry: {}, content: "fresh", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 } { entry: {}, content: "fresh", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
] ]
const res = Fuzzy.searchRows(rows, "", now, 10) assert.equal(Fuzzy.searchRows(rows, "", now, 10)[0].row.content, "fresh")
assert.equal(res[0].row.content, "zzz pinned weak") const pinned = Fuzzy.searchRows(rows, "is:pinned", now, 10)
assert.equal(pinned.length, 1)
assert.equal(pinned[0].row.content, "zzz pinned old")
}) })
test("searchRows respects limit", () => { test("searchRows respects limit", () => {
+56
View File
@@ -0,0 +1,56 @@
import os
import subprocess
import sys
import tempfile
import unittest
class WatcherInputTests(unittest.TestCase):
def helper(self, stdin, timeout=0.2):
return subprocess.Popen(
[sys.executable, "-c", """
import capture
import sys
import tracemalloc
tracemalloc.start()
drain = capture.drain_stdin
capture.drain_stdin = lambda: drain(timeout=float(sys.argv[1]))
capture.list_types = lambda: print('probed') or []
capture.main()
assert tracemalloc.get_traced_memory()[1] < 1024 * 1024
""", str(timeout)], stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
def finish(self, proc, expected, payload=None):
try:
out, err = proc.communicate(input=payload, timeout=8)
self.assertEqual(proc.returncode, 0, err.decode())
self.assertEqual(out, expected)
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
def test_large_payload_is_discarded_without_accumulation(self):
with tempfile.TemporaryFile() as source:
source.truncate(64 * 1024 * 1024)
self.finish(self.helper(source, timeout=5), b"probed\n")
def test_finite_pipe_still_probes(self):
self.finish(self.helper(subprocess.PIPE, timeout=5), b"probed\n",
payload=b"clipboard content\n" * 16384)
def test_stalled_pipe_exits_without_probing(self):
reader, writer = os.pipe()
try:
with os.fdopen(reader, "rb") as source:
self.finish(self.helper(source), b"")
finally:
os.close(writer)
def test_endless_source_exits_without_probing(self):
with open('/dev/zero', 'rb') as source:
self.finish(self.helper(source), b"")
def test_empty_input_still_probes(self):
self.finish(self.helper(subprocess.DEVNULL), b"probed\n")
+69
View File
@@ -0,0 +1,69 @@
import importlib.util
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('file_preview', Path(__file__).resolve().parents[1] / 'scripts/file-preview.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class FilePreviewTests(unittest.TestCase):
def test_text_is_bounded_and_path_preserved(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'a " quoted.txt'
path.write_text('a' * 10000)
data = module.preview(str(path))
self.assertEqual(data['bytes'], 10000)
self.assertEqual(len(data['text']), 8192)
self.assertTrue(data['truncated'])
self.assertEqual(data['path'], str(path))
def test_missing_file_and_folder(self):
with tempfile.TemporaryDirectory() as directory:
self.assertEqual(module.preview(directory)['kind'], 'Folder')
self.assertIn('error', module.preview(directory + '/missing'))
def test_special_file_is_not_read(self):
with tempfile.TemporaryDirectory() as directory:
path = directory + '/fifo.txt'
os.mkfifo(path)
self.assertEqual(module.preview(path)['kind'], 'Special file')
def test_missing_media_tools_preserve_basic_metadata(self):
with tempfile.NamedTemporaryFile(suffix='.mp4') as source:
with patch.object(module, 'read_bounded', return_value=None):
data = module.preview(source.name)
self.assertEqual(data['mime'], 'video/mp4')
self.assertIn('bytes', data)
self.assertNotIn('thumbnail', data)
@unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'ffmpeg optional')
def test_video_resolution_duration_and_thumbnail(self):
with tempfile.TemporaryDirectory() as directory:
path = directory + '/clip.mkv'
subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'color=c=red:s=160x90:d=1', '-c:v', 'ffv1', path], check=True, timeout=10)
data = module.preview(path)
self.assertEqual((data['width'], data['height']), (160, 90))
self.assertAlmostEqual(data['duration'], 1, places=1)
self.assertTrue(data['thumbnail'].startswith('data:image/jpeg;base64,'))
@unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'ffmpeg optional')
def test_audio_details(self):
with tempfile.TemporaryDirectory() as directory:
path = directory + '/audio.wav'
subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'sine=duration=1', path], check=True, timeout=10)
data = module.preview(path)
self.assertAlmostEqual(data['duration'], 1, places=1)
self.assertIn('44100 Hz', data['audio'])
self.assertNotIn('thumbnail', data)
def test_generic_binary_not_shown_as_text(self):
with tempfile.NamedTemporaryFile(suffix='.txt') as source:
source.write(b'abc\x00def')
source.flush()
self.assertNotIn('text', module.preview(source.name))