import QtQuick import Quickshell import Quickshell.Io import "clip.js" as Clip Item { id: root property SurfaceManager surfaces property Niri niriService property string pendingPasteId: "" property var pasteWindowId: null property int pasteAttempts: 0 property string focusReply: "" property bool privateMode: false property bool vaultCopyActive: false property bool vaultPauseAcknowledged: false property var rows: [] signal vaultCapturePaused property var lastAction: ({}) property bool persistenceReady: false readonly property int maxRows: 40 readonly property string historyPath: Quickshell.statePath("clipboard-history.json") // --- linear lookup over the raw persisted rows (id, title, body, pinned, createdAt) --- function rawRowById(id) { const needle = String(id); for (let i = 0; i < rows.length; i++) if (rows[i].id === needle) { return rows[i]; } return null; } function captureClipboard(text) { const body = String(text || ""); if (privateMode || !Clip.isReadableText(body)) return; const id = `clip-${Clip.hashText(body)}`; const next = []; let existingPin = false; for (let i = 0; i < rows.length; i++) { if (rows[i].id === id) { existingPin = !!rows[i].pinned; continue; } next.push(rows[i]); } next.unshift({ "id": id, "title": Clip.rowTitle(body), "body": body, "pinned": existingPin, "createdAt": Date.now() }); rows = next.slice(0, maxRows); saveHistory(); } // --- trimmed serialization: the view binds the cloned rows directly --- function stateObject(query) { const now = Date.now(); const viewRows = Clip.filterRows(rows, query).map(row => { return Clip.cloneRow(row, now); }); return { "privateMode": privateMode, "privateRole": privateMode ? "warning" : "ui.accent", "rows": viewRows, "lastAction": lastAction }; } function stateJson() { return JSON.stringify(stateObject("")); } function search(query) { return JSON.stringify(stateObject(query)); } function launcherRows(query) { const now = Date.now(); const result = Clip.filterRows(rows, query).map(row => { return Clip.cloneRow(row, now); }); for (let i = 0; i < result.length; i++) { result[i].subtitle = privateMode ? "private clipboard" : "clipboard"; result[i].keyboard = true; } return JSON.stringify(result); } function mutateRow(id, callback) { const next = []; for (let i = 0; i < rows.length; i++) { const row = Object.assign({}, rows[i]); if (row.id === String(id)) callback(row); next.push(row); } rows = next; saveHistory(); } function togglePin(id) { mutateRow(id, row => { return row.pinned = !row.pinned; }); lastAction = { "kind": "pin", "id": String(id) }; return stateJson(); } function copy(id) { const row = rawRowById(id); if (row) Quickshell.execDetached(["wl-copy", String(row.body || "")]); lastAction = { "kind": "copy", "id": String(id), "accepted": !!row }; return stateJson(); } function paste(id) { if (pendingPasteId.length > 0) return stateJson(); const target = surfaces && surfaces.popoutOpen ? surfaces.returnWindowId : (niriService ? niriService.focusedWindow : null); const accepted = !!rawRowById(id) && target !== null && target !== undefined; lastAction = { "kind": "paste", "id": String(id), "accepted": accepted, "pending": accepted, "error": accepted ? "" : "no paste destination", "route": "clipboard-target-typing", "pasteInputRoute": "wtype" }; if (!accepted) return stateJson(); pendingPasteId = String(id); pasteWindowId = target; pasteAttempts = 0; if (surfaces) surfaces.closePopout("clipboard-paste"); // Wait for observable compositor focus, not a guessed typing delay. pasteFocusTimer.start(); return stateJson(); } function finishPaste(error) { lastAction = Object.assign({}, lastAction, { "pending": false, "error": error }); pendingPasteId = ""; pasteWindowId = null; focusReply = ""; pasteFocusTimer.stop(); } function checkPasteFocus(exitCode) { if (pendingPasteId.length === 0) return; if (surfaces && surfaces.popoutOpen) { finishPaste("paste cancelled: another panel opened"); return; } let focused = null; try { focused = JSON.parse(focusReply); } catch (_) {} if (exitCode === 0 && focused && focused.id === pasteWindowId) { const row = rawRowById(pendingPasteId); if (row) Quickshell.execDetached(["/usr/bin/wtype", "--", String(row.body || "")]); finishPaste(row ? "" : "clipboard item no longer available"); } else if (++pasteAttempts >= 40 || (focused && focused.id !== pasteWindowId)) { finishPaste("paste destination lost focus"); } else { pasteFocusTimer.restart(); } } function deleteRow(id) { const target = String(id); const next = []; for (let i = 0; i < rows.length; i++) { if (rows[i].id !== target) next.push(rows[i]); } rows = next; saveHistory(); lastAction = { "kind": "delete", "id": target }; return stateJson(); } function loadHistoryText(text) { try { const parsed = JSON.parse(String(text || "{}")); const loadedRows = Array.isArray(parsed.rows) ? parsed.rows : []; rows = loadedRows.map(row => { return Clip.persistedRow(row); }).filter(row => { return row.id.length > 0 && Clip.isReadableText(row.body); }).slice(0, maxRows); } catch (error) { rows = []; } persistenceReady = true; saveHistory(); } function saveHistory() { if (!persistenceReady) return; const payload = { "version": 1, "rows": rows.map(row => { return Clip.persistedRow(row); }).slice(0, maxRows) }; clipboardHistoryFile.setText(JSON.stringify(payload, null, 2)); } function setPrivateMode(enabled) { privateMode = enabled; lastAction = { "kind": "private", "enabled": enabled }; return stateJson(); } function pauseForVaultCopy(timeoutSeconds) { vaultPauseAcknowledged = false; vaultCopyActive = true; vaultCopyFailsafe.interval = (Math.max(1, Number(timeoutSeconds || 30)) + 2) * 1000; vaultCopyFailsafe.restart(); if (!clipboardWatcher.running) { vaultPauseAcknowledged = true; vaultCapturePaused(); } } function resumeAfterVaultCopy() { vaultCopyFailsafe.stop(); vaultCopyActive = false; vaultPauseAcknowledged = false; } // --- exercises clip.js detect/sort/filter/clone through the service --- function selfTest() { const cases = []; function ok(name, cond) { cases.push({ "name": name, "pass": !!cond }); } try { ok("detect-url", Clip.detectKind("https://example.com/x").kind === "url"); ok("detect-color", Clip.detectKind("#ff0000").kind === "color"); ok("detect-image", Clip.detectKind("/tmp/shot.png").kind === "image"); ok("detect-path", Clip.detectKind("/etc/hosts").kind === "path"); ok("detect-text", Clip.detectKind("just some words").kind === "text"); const sorted = Clip.sortRows([ { "id": "a", "title": "a", "body": "a", "pinned": false, "createdAt": 100 }, { "id": "b", "title": "b", "body": "b", "pinned": false, "createdAt": 200 }, { "id": "c", "title": "c", "body": "c", "pinned": true, "createdAt": 50 } ]); ok("sort-pinned-first", sorted[0].id === "c"); ok("sort-recency-desc", sorted[1].id === "b" && sorted[2].id === "a"); const filtered = Clip.filterRows([ { "id": "x", "title": "hello", "body": "world", "pinned": false, "createdAt": 1 }, { "id": "y", "title": "foo", "body": "bar", "pinned": false, "createdAt": 2 } ], "foo"); ok("filter-match", filtered.length === 1 && filtered[0].id === "y"); const imageRow = Clip.cloneRow({ "id": "i", "body": "/tmp/p.png", "pinned": false, "createdAt": Date.now() }); ok("clone-image", imageRow.isImage === true && imageRow.title === "Image" && imageRow.imagePath === "/tmp/p.png"); const colorRow = Clip.cloneRow({ "id": "c2", "body": "#abc", "pinned": false, "createdAt": Date.now() }); ok("clone-swatch", colorRow.kind === "color" && colorRow.swatch === "#aabbcc"); } catch (error) { ok("threw:" + error, false); } const fail = cases.filter(c => { return !c.pass; }).length; return JSON.stringify({ "pass": cases.length - fail, "fail": fail, "cases": cases }); } FileView { id: clipboardHistoryFile path: root.historyPath blockWrites: true atomicWrites: true printErrors: false onLoaded: root.loadHistoryText(clipboardHistoryFile.text()) onLoadFailed: { root.persistenceReady = true; root.saveHistory(); } } Timer { id: pasteFocusTimer interval: 25 onTriggered: { root.focusReply = ""; pasteFocusQuery.running = true; } } Process { id: pasteFocusQuery command: ["/usr/bin/niri", "msg", "--json", "focused-window"] stdout: StdioCollector { onStreamFinished: root.focusReply = text } } Connections { target: pasteFocusQuery function onExited(exitCode) { root.checkPasteFocus(exitCode); } } Timer { id: vaultCopyFailsafe onTriggered: root.resumeAfterVaultCopy() } Process { id: clipboardWatcher command: ["wl-paste", "--type", "text/plain", "--watch", "sh", "-c", "cat; printf '\\036'"] running: !root.vaultCopyActive onRunningChanged: { if (root.vaultCopyActive && !running && !root.vaultPauseAcknowledged) { root.vaultPauseAcknowledged = true; root.vaultCapturePaused(); } } stdout: SplitParser { splitMarker: "\u001e" onRead: data => { return root.captureClipboard(data); } } } }