diff --git a/install.sh b/install.sh index 4fd8fcd..cb4bc00 100755 --- a/install.sh +++ b/install.sh @@ -37,7 +37,7 @@ echo "==> Enabling $PLUGIN_ID (replaces built-in omarchy.clipboard)" omarchy plugin enable "$PLUGIN_ID" echo "==> Rebinding ALT+SHIFT+V" -if [[ -f $BINDINGS ]] && grep -q "ALT SHIFT, V" "$BINDINGS"; then +if [[ -f $BINDINGS ]] && grep -q "^bindd = ALT SHIFT, V, " "$BINDINGS"; then cp "$BINDINGS" "$BINDINGS.bak.$(date +%s)" sed -i 's|^bindd = ALT SHIFT, V, .*|bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard|' "$BINDINGS" hyprctl reload >/dev/null diff --git a/plugin/Clipboard.qml b/plugin/Clipboard.qml index 51472cb..84a7ffd 100644 --- a/plugin/Clipboard.qml +++ b/plugin/Clipboard.qml @@ -88,18 +88,27 @@ Item { var pruned = Store.prune(root.history, root.historyLimit) root.history = pruned.entries historyFile.setText(JSON.stringify(root.history, null, 1) + "\n") - if (pruned.droppedImagePaths.length > 0) { - var cmd = ["rm", "-f"].concat(pruned.droppedImagePaths) - gcProc.command = cmd - gcProc.running = true - } + if (pruned.droppedImagePaths.length > 0) queueGc(pruned.droppedImagePaths) + } + + // Serialize GC batches: a single reusable Process would silently drop + // overlapping runs, so pending paths queue until the current rm exits. + property var gcQueue: [] + function queueGc(paths) { + root.gcQueue.push(paths) + if (!gcProc.running) runNextGc() + } + function runNextGc() { + if (root.gcQueue.length === 0) return + gcProc.command = ["rm", "-f"].concat(root.gcQueue.shift()) + gcProc.running = true } function addClipboardJson(line) { var entry = null try { entry = JSON.parse(String(line || "")) } catch (e) { return } if (!entry) return - root.history = Store.addEntry(root.history, entry, root.historyLimit, Math.floor(Date.now() / 1000)) + root.history = Store.addEntry(root.history, entry, Math.floor(Date.now() / 1000)) root.saveHistory() if (root.opened) root.rebuild() } @@ -242,10 +251,7 @@ Item { root.history = [] root.typeCache = {} root.saveHistory() - if (dropped.length > 0) { - gcProc.command = ["rm", "-f"].concat(dropped) - gcProc.running = true - } + if (dropped.length > 0) queueGc(dropped) root.selectedIndex = 0 root.clearConfirmOpen = false root.rebuild() @@ -271,7 +277,10 @@ Item { onFileChanged: reload() } - Process { id: gcProc } + Process { + id: gcProc + onExited: runNextGc() + } // Reap watchers left behind by a previous shell instance, then start our // own. pdeathsig kills them whenever the shell exits. @@ -674,7 +683,7 @@ Item { anchors.bottom: parent.bottom anchors.left: parent.left anchors.leftMargin: root.listWidth + Style.space(6) - width: 1 + width: Style.normalBorderWidth color: root.lineColor } diff --git a/plugin/Fuzzy.js b/plugin/Fuzzy.js index fcbebc5..76dedd4 100644 --- a/plugin/Fuzzy.js +++ b/plugin/Fuzzy.js @@ -65,10 +65,11 @@ function parseQuery(q) { var canonical = TYPE_ALIASES[wanted] if (canonical) { out.type = canonical; continue } // prefix match: "type:im" → image + var matchedType = "" for (var key in TYPE_ALIASES) { - if (key.indexOf(wanted) === 0) { out.type = TYPE_ALIASES[key]; break } + if (key.indexOf(wanted) === 0) { matchedType = TYPE_ALIASES[key]; break } } - if (out.type) continue + if (matchedType) { out.type = matchedType; continue } // unknown type — treat the whole token as a term out.terms.push(tok) continue @@ -91,7 +92,7 @@ function parseQuery(q) { if (AGE_WORDS[lower] !== undefined) { out.maxAge = AGE_WORDS[lower] - if (lower === "yesterday") out.minAge = 0 // refined below relative to now; caller passes now + if (lower === "yesterday") out.minAge = 86400 continue } @@ -219,14 +220,6 @@ function ageFilterOk(parsed, ageSeconds) { return true } -// "yesterday" means older than 24h but within 48h. -function refineYesterday(parsed, now) { - // Only applies when the query contained the bare word "yesterday" and no - // explicit duration bounds; approximated by caller passing minAge via the - // maxAge=172800 already set. We require age > 86400 - slack handled here. - return parsed -} - function recencyBonus(ts, now) { if (!ts) return 0 var ageDays = Math.max(0, (now - ts) / 86400) diff --git a/plugin/PreviewPane.qml b/plugin/PreviewPane.qml index 6f04711..9322beb 100644 --- a/plugin/PreviewPane.qml +++ b/plugin/PreviewPane.qml @@ -163,7 +163,7 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.topMargin: Style.space(10) - height: 1 + height: Style.normalBorderWidth color: root.lineColor } @@ -224,7 +224,7 @@ Item { height: Style.space(120) radius: Style.cornerRadius color: root.hexRe.test(root.rawSafe()) ? root.rawSafe() : "transparent" - border.width: 1 + border.width: Style.normalBorderWidth border.color: root.lineColor } @@ -322,7 +322,7 @@ Item { Rectangle { anchors.fill: img color: "transparent" - border.width: 1 + border.width: Style.normalBorderWidth border.color: root.lineColor radius: Style.space(4) visible: img.status === Image.Ready diff --git a/plugin/Store.js b/plugin/Store.js index a2a6f1b..8fd553e 100644 --- a/plugin/Store.js +++ b/plugin/Store.js @@ -86,17 +86,17 @@ function parseHistory(raw, now) { } // Add (or bump) an entry; newest first. Keeps pin state and usage count of -// the existing copy when the same content is copied again. -function addEntry(history, entry, limit, now) { +// the existing copy when the same content is copied again. Does NOT truncate +// to the limit: the caller prunes via prune() so evicted image blobs can be +// garbage-collected (truncating here would leak them). +function addEntry(history, entry, now) { var normalized = normalize(entry, now) - var max = limit === undefined || limit === null ? DEFAULT_LIMIT : Number(limit) - if (isNaN(max) || max < 1) max = DEFAULT_LIMIT - if (!normalized) return Array.isArray(history) ? history.slice(0, max) : [] + if (!normalized) return Array.isArray(history) ? history.slice() : [] var key = entryKey(normalized) var next = [normalized] var values = Array.isArray(history) ? history : [] - for (var i = 0; i < values.length && next.length < max; i++) { + for (var i = 0; i < values.length; i++) { var existing = normalize(values[i], now) if (!existing) continue if (entryKey(existing) === key) { diff --git a/plugin/__pycache__/capture.cpython-314.pyc b/plugin/__pycache__/capture.cpython-314.pyc new file mode 100644 index 0000000..101baf1 Binary files /dev/null and b/plugin/__pycache__/capture.cpython-314.pyc differ diff --git a/plugin/capture.py b/plugin/capture.py index 535f144..f9a7d78 100755 --- a/plugin/capture.py +++ b/plugin/capture.py @@ -99,6 +99,7 @@ def capture_image(types, app): pass return emit(entry) + return True def decode_text(data): @@ -159,7 +160,13 @@ def capture_text(types, app): def main(): - sys.stdin.close() # watcher payload unused; we probe ourselves + # The watcher pipes the clipboard payload to us; wl-clipboard blocks on + # writes if we close the pipe early, so drain it instead (we still probe + # types ourselves below). + try: + sys.stdin.buffer.read() + except Exception: + pass types = list_types() if not types: return diff --git a/plugin/open-entry.sh b/plugin/open-entry.sh index fce3e91..0d49cf2 100755 --- a/plugin/open-entry.sh +++ b/plugin/open-entry.sh @@ -28,6 +28,8 @@ open_text() { local dir file dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/clipboard-open" mkdir -p "$dir" + # Prune temp copies older than a week; nothing else reclaims them. + find "$dir" -type f -name 'clipboard.*.txt' -mtime +7 -delete 2>/dev/null || true file=$(mktemp --tmpdir="$dir" clipboard.XXXXXX.txt) printf '%s' "$text" >"$file" exec omarchy-launch-editor "$file" @@ -43,7 +45,7 @@ case $(jq -r '.type' <<<"$entry") in exec xdg-open "$path" ;; files) - first=$(jq -r '.paths[0]' <<<"$entry") + first=$(jq -r '.paths[0] // empty' <<<"$entry") [[ -n $first ]] || exit 0 exec xdg-open "$first" ;; diff --git a/plugin/paste-entry.sh b/plugin/paste-entry.sh index e5ed995..a96f227 100755 --- a/plugin/paste-entry.sh +++ b/plugin/paste-entry.sh @@ -23,7 +23,7 @@ case "$type" in ;; files) while IFS= read -r p; do - printf 'file://%s\n' "$p" + printf 'file://%s\n' "$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$p")" done < <(jq -r '.paths[]' <<<"$entry") | wl-copy --type text/uri-list ;; *) diff --git a/tests/test-fuzzy.mjs b/tests/test-fuzzy.mjs index 3068371..cb0a5b4 100644 --- a/tests/test-fuzzy.mjs +++ b/tests/test-fuzzy.mjs @@ -156,7 +156,15 @@ test("highlightFirstLine multiline only first line", () => { assert.ok(!html.includes("\n")) }) -test("parseQuery yesterday bounds", () => { +test("parseQuery yesterday excludes today", () => { const q = Fuzzy.parseQuery("yesterday") assert.equal(q.maxAge, 172800) + assert.equal(q.minAge, 86400) +}) + +test("regression: unknown type: token becomes a term even after a known one", () => { + const q = Fuzzy.parseQuery("type:image type:foo") + assert.equal(q.type, "image") + assert.equal(q.terms.length, 1) + assert.equal(q.terms[0], "type:foo") }) diff --git a/tests/test-store.mjs b/tests/test-store.mjs index 9ae4f0a..3d014a5 100644 --- a/tests/test-store.mjs +++ b/tests/test-store.mjs @@ -46,11 +46,24 @@ test("addEntry dedupes and bumps to front, keeps pin/uses", () => { assert.equal(h[0].uses, 3) }) -test("addEntry respects limit", () => { +test("addEntry keeps all entries; prune truncates", () => { let h = [] - for (let i = 0; i < 20; i++) h = Store.addEntry(h, { type: "text", text: "t" + i, ts: i }, 5) - assert.equal(h.length, 5) - assert.equal(h[0].text, "t19") + for (let i = 0; i < 20; i++) h = Store.addEntry(h, { type: "text", text: "t" + i, ts: i }) + assert.equal(h.length, 20) + const r = Store.prune(h, 5) + assert.equal(r.entries.length, 5) + assert.equal(r.entries[0].text, "t19") +}) + +test("regression: addEntry at limit must not leak evicted images (prune reports them)", () => { + // Images beyond the limit are only reclaimable if prune() sees them, + // which is why addEntry must not truncate by itself. + let h = [] + for (let i = 0; i < 10; i++) + h = Store.addEntry(h, { type: i < 2 ? "image" : "text", path: "/tmp/x" + i + ".png", text: "t" + i, ts: i }) + const r = Store.prune(h, 5) + assert.ok(r.droppedImagePaths.includes("/tmp/x0.png")) + assert.ok(r.droppedImagePaths.includes("/tmp/x1.png")) }) test("removeById / findById / togglePin / touch", () => {