Launcher: deleted ClipboardService, renamed CliphistService to ClipboardService.
This commit is contained in:
parent
132dbce3a3
commit
20b29f98a7
5 changed files with 314 additions and 534 deletions
|
|
@ -255,8 +255,8 @@ NPanel {
|
||||||
// When this delegate is assigned a new image item, trigger the decode.
|
// When this delegate is assigned a new image item, trigger the decode.
|
||||||
onCurrentClipboardIdChanged: {
|
onCurrentClipboardIdChanged: {
|
||||||
// Check if it's a valid ID and if the data isn't already cached.
|
// Check if it's a valid ID and if the data isn't already cached.
|
||||||
if (currentClipboardId && !CliphistService.getImageData(currentClipboardId)) {
|
if (currentClipboardId && !ClipboardService.getImageData(currentClipboardId)) {
|
||||||
CliphistService.decodeToDataUrl(currentClipboardId, modelData.mime, null)
|
ClipboardService.decodeToDataUrl(currentClipboardId, modelData.mime, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,14 +293,14 @@ NPanel {
|
||||||
visible: modelData.isImage
|
visible: modelData.isImage
|
||||||
|
|
||||||
// This property creates a dependency on the service's revision counter
|
// This property creates a dependency on the service's revision counter
|
||||||
readonly property int _rev: CliphistService.revision
|
readonly property int _rev: ClipboardService.revision
|
||||||
|
|
||||||
// Fetches from the service's cache.
|
// Fetches from the service's cache.
|
||||||
// The dependency on `_rev` ensures this binding is re-evaluated
|
// The dependency on `_rev` ensures this binding is re-evaluated
|
||||||
// when the cache is updated by the service.
|
// when the cache is updated by the service.
|
||||||
source: {
|
source: {
|
||||||
_rev
|
_rev
|
||||||
return CliphistService.getImageData(modelData.clipboardId) || ""
|
return ClipboardService.getImageData(modelData.clipboardId) || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
fillMode: Image.PreserveAspectFit
|
fillMode: Image.PreserveAspectFit
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,7 @@ QtObject {
|
||||||
"icon": "accessories-calculator",
|
"icon": "accessories-calculator",
|
||||||
"isImage": false,
|
"isImage": false,
|
||||||
"onActivate": function () {
|
"onActivate": function () {
|
||||||
// Copy result to clipboard if service available
|
// TODO: copy entry to clipboard via ClipHist
|
||||||
// if (typeof ClipboardService !== 'undefined') {
|
|
||||||
// ClipboardService.copy(result.toString())
|
|
||||||
// }
|
|
||||||
launcher.close()
|
launcher.close()
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ QtObject {
|
||||||
// Plugin capabilities
|
// Plugin capabilities
|
||||||
property bool handleSearch: false // Don't handle regular search
|
property bool handleSearch: false // Don't handle regular search
|
||||||
|
|
||||||
|
// Listen for clipboard data updates
|
||||||
// Connections {
|
// Connections {
|
||||||
// target: CliphistService
|
// target: ClipboardService
|
||||||
// // Use the function syntax for on<SignalName>
|
|
||||||
// function onListCompleted() {
|
// function onListCompleted() {
|
||||||
// // Only refresh if the clipboard plugin is active
|
// // Only refresh if the clipboard plugin is active
|
||||||
// if (launcher && launcher.activePlugin === root) {
|
// if (launcher && launcher.activePlugin === root) {
|
||||||
|
|
@ -32,7 +32,7 @@ QtObject {
|
||||||
// Called when launcher opens
|
// Called when launcher opens
|
||||||
function onOpened() {
|
function onOpened() {
|
||||||
// Refresh clipboard history when launcher opens
|
// Refresh clipboard history when launcher opens
|
||||||
CliphistService.list(100)
|
ClipboardService.list(100)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this plugin handles the command
|
// Check if this plugin handles the command
|
||||||
|
|
@ -56,7 +56,7 @@ QtObject {
|
||||||
"icon": "delete_sweep",
|
"icon": "delete_sweep",
|
||||||
"isImage": false,
|
"isImage": false,
|
||||||
"onActivate": function () {
|
"onActivate": function () {
|
||||||
CliphistService.wipeAll()
|
ClipboardService.wipeAll()
|
||||||
launcher.close()
|
launcher.close()
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
|
|
@ -78,18 +78,29 @@ QtObject {
|
||||||
"description": "Remove all items from clipboard history",
|
"description": "Remove all items from clipboard history",
|
||||||
"icon": "delete_sweep",
|
"icon": "delete_sweep",
|
||||||
"onActivate": function () {
|
"onActivate": function () {
|
||||||
CliphistService.wipeAll()
|
ClipboardService.wipeAll()
|
||||||
launcher.close()
|
launcher.close()
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show loading state if data isn't ready yet
|
||||||
|
if (ClipboardService.loading) {
|
||||||
|
return [{
|
||||||
|
"name": "Loading clipboard history...",
|
||||||
|
"description": "Please wait",
|
||||||
|
"icon": "view-refresh",
|
||||||
|
"isImage": false,
|
||||||
|
"onActivate": function () {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
// Search clipboard items
|
// Search clipboard items
|
||||||
const searchTerm = query.toLowerCase()
|
const searchTerm = query.toLowerCase()
|
||||||
|
|
||||||
// Force dependency update
|
// Force dependency update
|
||||||
const _rev = CliphistService.revision
|
const _rev = ClipboardService.revision
|
||||||
const items = CliphistService.items || []
|
const items = ClipboardService.items || []
|
||||||
|
|
||||||
// Filter and format results
|
// Filter and format results
|
||||||
items.forEach(function (item) {
|
items.forEach(function (item) {
|
||||||
|
|
@ -110,7 +121,7 @@ QtObject {
|
||||||
|
|
||||||
// Add activation handler
|
// Add activation handler
|
||||||
entry.onActivate = function () {
|
entry.onActivate = function () {
|
||||||
CliphistService.copyToClipboard(item.id)
|
ClipboardService.copyToClipboard(item.id)
|
||||||
launcher.close()
|
launcher.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,13 +133,14 @@ QtObject {
|
||||||
results.push({
|
results.push({
|
||||||
"name": searchTerm ? "No matching clipboard items" : "Clipboard is empty",
|
"name": searchTerm ? "No matching clipboard items" : "Clipboard is empty",
|
||||||
"description": searchTerm ? `No items containing "${query}"` : "Copy something to see it here",
|
"description": searchTerm ? `No items containing "${query}"` : "Copy something to see it here",
|
||||||
"icon": "content_paste_off",
|
"icon": "text-x-generic",
|
||||||
"isImage": false,
|
"isImage": false,
|
||||||
"onActivate": function () {// Do nothing
|
"onActivate": function () {// Do nothing
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.log("ClipboardPlugin", `Returning ${results.length} results`)
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,8 +157,7 @@ QtObject {
|
||||||
"isImage": true,
|
"isImage": true,
|
||||||
"imageWidth": meta ? meta.w : 0,
|
"imageWidth": meta ? meta.w : 0,
|
||||||
"imageHeight": meta ? meta.h : 0,
|
"imageHeight": meta ? meta.h : 0,
|
||||||
"clipboardId"// Provide the ID and mime type for the delegate to make an async request
|
"clipboardId": item.id,
|
||||||
: item.id,
|
|
||||||
"mime": item.mime
|
"mime": item.mime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +214,6 @@ QtObject {
|
||||||
// Public method to get image data for a clipboard item
|
// Public method to get image data for a clipboard item
|
||||||
// This can be called by the launcher when rendering
|
// This can be called by the launcher when rendering
|
||||||
function getImageForItem(clipboardId) {
|
function getImageForItem(clipboardId) {
|
||||||
return CliphistService.getImageData ? CliphistService.getImageData(clipboardId) : null
|
return ClipboardService.getImageData ? ClipboardService.getImageData(clipboardId) : null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,225 +4,335 @@ import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
import qs.Services
|
|
||||||
|
|
||||||
|
// Thin wrapper around the cliphist CLI
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property var history: []
|
// Public API
|
||||||
property bool initialized: false
|
property var items: [] // [{id, preview, mime, isImage}]
|
||||||
property int maxHistory: 50 // Limit clipboard history entries
|
property bool loading: false
|
||||||
|
// Active only when feature is enabled, settings have finished initial load, and cliphist is available
|
||||||
|
property bool active: Settings.data.appLauncher.enableClipboardHistory && Settings.isLoaded && cliphistAvailable
|
||||||
|
|
||||||
// Internal state
|
// Check if cliphist is available on the system
|
||||||
property bool _enabled: true
|
property bool cliphistAvailable: false
|
||||||
|
property bool dependencyChecked: false
|
||||||
|
|
||||||
// Cached history file path
|
// Optional automatic watchers to feed cliphist DB
|
||||||
property string historyFile: Quickshell.env("NOCTALIA_CLIPBOARD_HISTORY_FILE")
|
property bool autoWatch: true
|
||||||
|| (Settings.cacheDir + "clipboard.json")
|
property bool watchersStarted: false
|
||||||
|
|
||||||
// Persisted storage for clipboard history
|
// Expose decoded thumbnails by id and a revision to notify bindings
|
||||||
property FileView historyFileView: FileView {
|
property var imageDataById: ({})
|
||||||
id: historyFileView
|
property int revision: 0
|
||||||
objectName: "clipboardHistoryFileView"
|
|
||||||
path: historyFile
|
// Approximate first-seen timestamps for entries this session (seconds)
|
||||||
watchChanges: false // We don't need to watch changes for clipboard
|
property var firstSeenById: ({})
|
||||||
onAdapterUpdated: writeAdapter()
|
|
||||||
Component.onCompleted: reload()
|
// Internal: store callback for decode
|
||||||
onLoaded: loadFromHistory()
|
property var _decodeCallback: null
|
||||||
onLoadFailed: function (error) {
|
|
||||||
// Create file on first use
|
// Queue for base64 decodes
|
||||||
if (error.toString().includes("No such file") || error === 2) {
|
property var _b64Queue: []
|
||||||
writeAdapter()
|
property var _b64CurrentCb: null
|
||||||
}
|
property string _b64CurrentMime: ""
|
||||||
|
property string _b64CurrentId: ""
|
||||||
|
|
||||||
|
signal listCompleted()
|
||||||
|
|
||||||
|
// Check if cliphist is available
|
||||||
|
Component.onCompleted: {
|
||||||
|
checkCliphistAvailability()
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonAdapter {
|
// Check dependency availability
|
||||||
id: historyAdapter
|
function checkCliphistAvailability() {
|
||||||
property var history: []
|
if (dependencyChecked)
|
||||||
property double timestamp: 0
|
return
|
||||||
}
|
|
||||||
|
dependencyCheckProcess.command = ["which", "cliphist"]
|
||||||
|
dependencyCheckProcess.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
// Process to check if cliphist is available
|
||||||
interval: 2000
|
|
||||||
repeat: true
|
|
||||||
running: root._enabled
|
|
||||||
onTriggered: root.refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect current clipboard types (text/image)
|
|
||||||
Process {
|
Process {
|
||||||
id: typeProcess
|
id: dependencyCheckProcess
|
||||||
property bool isLoading: false
|
stdout: StdioCollector {}
|
||||||
property var currentTypes: []
|
|
||||||
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.dependencyChecked = true
|
||||||
if (exitCode === 0) {
|
if (exitCode === 0) {
|
||||||
currentTypes = String(stdout.text).trim().split('\n').filter(t => t)
|
root.cliphistAvailable = true
|
||||||
|
// Start watchers if feature is enabled
|
||||||
// Always check for text first
|
if (root.active) {
|
||||||
textProcess.command = ["wl-paste", "-n", "--type", "text/plain"]
|
startWatchers()
|
||||||
textProcess.isLoading = true
|
|
||||||
textProcess.running = true
|
|
||||||
|
|
||||||
// Also check for images if available
|
|
||||||
const imageType = currentTypes.find(t => t.startsWith('image/'))
|
|
||||||
if (imageType) {
|
|
||||||
imageProcess.mimeType = imageType
|
|
||||||
imageProcess.command = ["sh", "-c", `wl-paste -n -t "${imageType}" | base64 -w 0`]
|
|
||||||
imageProcess.running = true
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
typeProcess.isLoading = false
|
root.cliphistAvailable = false
|
||||||
|
// Show toast notification if feature is enabled but cliphist is missing
|
||||||
|
if (Settings.data.appLauncher.enableClipboardHistory) {
|
||||||
|
ToastService.showWarning(
|
||||||
|
"Clipboard History Unavailable",
|
||||||
|
"The 'cliphist' application is not installed. Please install it to use clipboard history features.",
|
||||||
|
false, 6000)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout: StdioCollector {}
|
// Start/stop watchers when enabled changes
|
||||||
|
onActiveChanged: {
|
||||||
|
if (root.active && root.cliphistAvailable) {
|
||||||
|
startWatchers()
|
||||||
|
} else {
|
||||||
|
stopWatchers()
|
||||||
|
loading = false
|
||||||
|
// Optional: clear items to avoid stale UI
|
||||||
|
items = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read image data
|
// Fallback: periodically refresh list so UI updates even if not in clip mode
|
||||||
|
Timer {
|
||||||
|
interval: 5000
|
||||||
|
repeat: true
|
||||||
|
running: root.active && root.cliphistAvailable
|
||||||
|
onTriggered: list()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal process objects
|
||||||
Process {
|
Process {
|
||||||
id: imageProcess
|
id: listProc
|
||||||
property string mimeType: ""
|
|
||||||
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
if (exitCode === 0) {
|
|
||||||
const base64 = stdout.text.trim()
|
|
||||||
if (base64) {
|
|
||||||
const entry = {
|
|
||||||
"type": 'image',
|
|
||||||
"mimeType": mimeType,
|
|
||||||
"data": `data:${mimeType};base64,${base64}`,
|
|
||||||
"timestamp": new Date().getTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this exact image already exists
|
|
||||||
const exists = root.history.find(item => item.type === 'image' && item.data === entry.data)
|
|
||||||
if (!exists) {
|
|
||||||
// Normalize existing history and add the new image
|
|
||||||
const normalizedHistory = root.history.map(item => {
|
|
||||||
if (typeof item === 'string') {
|
|
||||||
return {
|
|
||||||
"type": 'text',
|
|
||||||
"content": item,
|
|
||||||
"timestamp": new Date().getTime(
|
|
||||||
) - 1000 // Make it slightly older
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
root.history = [entry, ...normalizedHistory].slice(0, maxHistory)
|
|
||||||
saveHistory()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always mark as initialized when done
|
|
||||||
if (!textProcess.isLoading) {
|
|
||||||
root.initialized = true
|
|
||||||
typeProcess.isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stdout: StdioCollector {}
|
stdout: StdioCollector {}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
const out = String(stdout.text)
|
||||||
|
const lines = out.split('\n').filter(l => l.length > 0)
|
||||||
|
// cliphist list default format: "<id> <preview>" or "<id>\t<preview>"
|
||||||
|
const parsed = lines.map(l => {
|
||||||
|
let id = ""
|
||||||
|
let preview = ""
|
||||||
|
const m = l.match(/^(\d+)\s+(.+)$/)
|
||||||
|
if (m) {
|
||||||
|
id = m[1]
|
||||||
|
preview = m[2]
|
||||||
|
} else {
|
||||||
|
const tab = l.indexOf('\t')
|
||||||
|
id = tab > -1 ? l.slice(0, tab) : l
|
||||||
|
preview = tab > -1 ? l.slice(tab + 1) : ""
|
||||||
|
}
|
||||||
|
const lower = preview.toLowerCase()
|
||||||
|
const isImage = lower.startsWith("[image]") || lower.includes(" binary data ")
|
||||||
|
// Best-effort mime guess from preview
|
||||||
|
var mime = "text/plain"
|
||||||
|
if (isImage) {
|
||||||
|
if (lower.includes(" png"))
|
||||||
|
mime = "image/png"
|
||||||
|
else if (lower.includes(" jpg") || lower.includes(" jpeg"))
|
||||||
|
mime = "image/jpeg"
|
||||||
|
else if (lower.includes(" webp"))
|
||||||
|
mime = "image/webp"
|
||||||
|
else if (lower.includes(" gif"))
|
||||||
|
mime = "image/gif"
|
||||||
|
else
|
||||||
|
mime = "image/*"
|
||||||
|
}
|
||||||
|
// Record first seen time for new ids (approximate copy time)
|
||||||
|
if (!root.firstSeenById[id]) {
|
||||||
|
root.firstSeenById[id] = Time.timestamp
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"id": id,
|
||||||
|
"preview": preview,
|
||||||
|
"isImage": isImage,
|
||||||
|
"mime": mime
|
||||||
|
}
|
||||||
|
})
|
||||||
|
items = parsed
|
||||||
|
loading = false
|
||||||
|
|
||||||
|
// Emit the signal for subscribers
|
||||||
|
root.listCompleted()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read text data
|
|
||||||
Process {
|
Process {
|
||||||
id: textProcess
|
id: decodeProc
|
||||||
property bool isLoading: false
|
stdout: StdioCollector {}
|
||||||
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
textProcess.isLoading = false
|
const out = String(stdout.text)
|
||||||
|
if (root._decodeCallback) {
|
||||||
if (exitCode === 0) {
|
try {
|
||||||
const content = String(stdout.text).trim()
|
root._decodeCallback(out)
|
||||||
if (content && content.length > 0) {
|
} finally {
|
||||||
const entry = {
|
root._decodeCallback = null
|
||||||
"type": 'text',
|
|
||||||
"content": content,
|
|
||||||
"timestamp": new Date().getTime()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this exact text content already exists
|
|
||||||
const exists = root.history.find(item => {
|
|
||||||
if (item.type === 'text') {
|
|
||||||
return item.content === content
|
|
||||||
}
|
|
||||||
return item === content
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
// Normalize existing history entries
|
|
||||||
const normalizedHistory = root.history.map(item => {
|
|
||||||
if (typeof item === 'string') {
|
|
||||||
return {
|
|
||||||
"type": 'text',
|
|
||||||
"content": item,
|
|
||||||
"timestamp": new Date().getTime(
|
|
||||||
) - 1000 // Make it slightly older
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
|
|
||||||
root.history = [entry, ...normalizedHistory].slice(0, maxHistory)
|
|
||||||
saveHistory()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as initialized and clean up loading states
|
Process {
|
||||||
root.initialized = true
|
id: copyProc
|
||||||
if (!imageProcess.running) {
|
|
||||||
typeProcess.isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stdout: StdioCollector {}
|
stdout: StdioCollector {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {
|
// Base64 decode pipeline (queued)
|
||||||
if (!typeProcess.isLoading && !textProcess.isLoading && !imageProcess.running) {
|
Process {
|
||||||
typeProcess.isLoading = true
|
id: decodeB64Proc
|
||||||
typeProcess.command = ["wl-paste", "-l"]
|
stdout: StdioCollector {}
|
||||||
typeProcess.running = true
|
onExited: (exitCode, exitStatus) => {
|
||||||
}
|
const b64 = String(stdout.text).trim()
|
||||||
}
|
if (root._b64CurrentCb) {
|
||||||
|
const url = `data:${root._b64CurrentMime};base64,${b64}`
|
||||||
function loadFromHistory() {
|
|
||||||
// Populate in-memory history from cached file
|
|
||||||
try {
|
try {
|
||||||
const items = historyAdapter.history || []
|
root._b64CurrentCb(url)
|
||||||
root.history = items.slice(0, maxHistory) // Apply limit when loading
|
} finally {
|
||||||
Logger.log("Clipboard", "Loaded", root.history.length, "entries from cache")
|
|
||||||
} catch (e) {
|
/* noop */ }
|
||||||
Logger.error("Clipboard", "Failed to load history:", e)
|
}
|
||||||
root.history = []
|
if (root._b64CurrentId !== "") {
|
||||||
|
root.imageDataById[root._b64CurrentId] = `data:${root._b64CurrentMime};base64,${b64}`
|
||||||
|
root.revision += 1
|
||||||
|
}
|
||||||
|
root._b64CurrentCb = null
|
||||||
|
root._b64CurrentMime = ""
|
||||||
|
root._b64CurrentId = ""
|
||||||
|
Qt.callLater(root._startNextB64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveHistory() {
|
// Long-running watchers to store new clipboard contents
|
||||||
try {
|
Process {
|
||||||
// Ensure we don't exceed the maximum history limit
|
id: watchText
|
||||||
const limitedHistory = root.history.slice(0, maxHistory)
|
stdout: StdioCollector {}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
historyAdapter.history = limitedHistory
|
// Auto-restart if watcher dies
|
||||||
historyAdapter.timestamp = Time.timestamp
|
if (root.autoWatch)
|
||||||
|
Qt.callLater(() => {
|
||||||
// Ensure cache directory exists
|
running = true
|
||||||
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir])
|
})
|
||||||
|
}
|
||||||
Qt.callLater(function () {
|
}
|
||||||
historyFileView.writeAdapter()
|
Process {
|
||||||
|
id: watchImage
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (root.autoWatch)
|
||||||
|
Qt.callLater(() => {
|
||||||
|
running = true
|
||||||
})
|
})
|
||||||
} catch (e) {
|
|
||||||
Logger.error("Clipboard", "Failed to save history:", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearHistory() {
|
function startWatchers() {
|
||||||
root.history = []
|
if (!root.active || !autoWatch || watchersStarted || !root.cliphistAvailable)
|
||||||
saveHistory()
|
return
|
||||||
|
watchersStarted = true
|
||||||
|
// Start text watcher
|
||||||
|
watchText.command = ["wl-paste", "--type", "text", "--watch", "cliphist", "store"]
|
||||||
|
watchText.running = true
|
||||||
|
// Start image watcher
|
||||||
|
watchImage.command = ["wl-paste", "--type", "image", "--watch", "cliphist", "store"]
|
||||||
|
watchImage.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopWatchers() {
|
||||||
|
if (!watchersStarted)
|
||||||
|
return
|
||||||
|
watchText.running = false
|
||||||
|
watchImage.running = false
|
||||||
|
watchersStarted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function list(maxPreviewWidth) {
|
||||||
|
if (!root.active || !root.cliphistAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (listProc.running)
|
||||||
|
return
|
||||||
|
loading = true
|
||||||
|
const width = maxPreviewWidth || 100
|
||||||
|
listProc.command = ["cliphist", "list", "-preview-width", String(width)]
|
||||||
|
listProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode(id, cb) {
|
||||||
|
if (!root.cliphistAvailable) {
|
||||||
|
if (cb)
|
||||||
|
cb("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root._decodeCallback = cb
|
||||||
|
decodeProc.command = ["cliphist", "decode", id]
|
||||||
|
decodeProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeToDataUrl(id, mime, cb) {
|
||||||
|
if (!root.cliphistAvailable) {
|
||||||
|
if (cb)
|
||||||
|
cb("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If cached, return immediately
|
||||||
|
if (root.imageDataById[id]) {
|
||||||
|
if (cb)
|
||||||
|
cb(root.imageDataById[id])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Queue request; ensures single process handles sequentially
|
||||||
|
root._b64Queue.push({
|
||||||
|
"id": id,
|
||||||
|
"mime": mime || "image/*",
|
||||||
|
"cb": cb
|
||||||
|
})
|
||||||
|
if (!decodeB64Proc.running && root._b64CurrentCb === null) {
|
||||||
|
_startNextB64()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageData(id) {
|
||||||
|
if (id === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return root.imageDataById[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startNextB64() {
|
||||||
|
if (root._b64Queue.length === 0 || !root.cliphistAvailable)
|
||||||
|
return
|
||||||
|
const job = root._b64Queue.shift()
|
||||||
|
root._b64CurrentCb = job.cb
|
||||||
|
root._b64CurrentMime = job.mime
|
||||||
|
root._b64CurrentId = job.id
|
||||||
|
decodeB64Proc.command = ["sh", "-lc", `cliphist decode ${job.id} | base64 -w 0`]
|
||||||
|
decodeB64Proc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(id) {
|
||||||
|
if (!root.cliphistAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// decode and pipe to wl-copy; implement via shell to preserve binary
|
||||||
|
copyProc.command = ["sh", "-lc", `cliphist decode ${id} | wl-copy`]
|
||||||
|
copyProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteById(id) {
|
||||||
|
if (!root.cliphistAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Quickshell.execDetached(["cliphist", "delete", id])
|
||||||
|
Qt.callLater(() => list())
|
||||||
|
}
|
||||||
|
|
||||||
|
function wipeAll() {
|
||||||
|
if (!root.cliphistAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Quickshell.execDetached(["cliphist", "wipe"])
|
||||||
|
|
||||||
|
revision++
|
||||||
|
|
||||||
|
Qt.callLater(() => list())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,338 +0,0 @@
|
||||||
pragma Singleton
|
|
||||||
|
|
||||||
import QtQuick
|
|
||||||
import Quickshell
|
|
||||||
import Quickshell.Io
|
|
||||||
import qs.Commons
|
|
||||||
|
|
||||||
// Thin wrapper around the cliphist CLI
|
|
||||||
Singleton {
|
|
||||||
id: root
|
|
||||||
|
|
||||||
// Public API
|
|
||||||
property var items: [] // [{id, preview, mime, isImage}]
|
|
||||||
property bool loading: false
|
|
||||||
// Active only when feature is enabled, settings have finished initial load, and cliphist is available
|
|
||||||
property bool active: Settings.data.appLauncher.enableClipboardHistory && Settings.isLoaded && cliphistAvailable
|
|
||||||
|
|
||||||
// Check if cliphist is available on the system
|
|
||||||
property bool cliphistAvailable: false
|
|
||||||
property bool dependencyChecked: false
|
|
||||||
|
|
||||||
// Optional automatic watchers to feed cliphist DB
|
|
||||||
property bool autoWatch: true
|
|
||||||
property bool watchersStarted: false
|
|
||||||
|
|
||||||
// Expose decoded thumbnails by id and a revision to notify bindings
|
|
||||||
property var imageDataById: ({})
|
|
||||||
property int revision: 0
|
|
||||||
|
|
||||||
// Approximate first-seen timestamps for entries this session (seconds)
|
|
||||||
property var firstSeenById: ({})
|
|
||||||
|
|
||||||
// Internal: store callback for decode
|
|
||||||
property var _decodeCallback: null
|
|
||||||
|
|
||||||
// Queue for base64 decodes
|
|
||||||
property var _b64Queue: []
|
|
||||||
property var _b64CurrentCb: null
|
|
||||||
property string _b64CurrentMime: ""
|
|
||||||
property string _b64CurrentId: ""
|
|
||||||
|
|
||||||
signal listCompleted()
|
|
||||||
|
|
||||||
// Check if cliphist is available
|
|
||||||
Component.onCompleted: {
|
|
||||||
checkCliphistAvailability()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check dependency availability
|
|
||||||
function checkCliphistAvailability() {
|
|
||||||
if (dependencyChecked)
|
|
||||||
return
|
|
||||||
|
|
||||||
dependencyCheckProcess.command = ["which", "cliphist"]
|
|
||||||
dependencyCheckProcess.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process to check if cliphist is available
|
|
||||||
Process {
|
|
||||||
id: dependencyCheckProcess
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
root.dependencyChecked = true
|
|
||||||
if (exitCode === 0) {
|
|
||||||
root.cliphistAvailable = true
|
|
||||||
// Start watchers if feature is enabled
|
|
||||||
if (root.active) {
|
|
||||||
startWatchers()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
root.cliphistAvailable = false
|
|
||||||
// Show toast notification if feature is enabled but cliphist is missing
|
|
||||||
if (Settings.data.appLauncher.enableClipboardHistory) {
|
|
||||||
ToastService.showWarning(
|
|
||||||
"Clipboard History Unavailable",
|
|
||||||
"The 'cliphist' application is not installed. Please install it to use clipboard history features.",
|
|
||||||
false, 6000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start/stop watchers when enabled changes
|
|
||||||
onActiveChanged: {
|
|
||||||
if (root.active && root.cliphistAvailable) {
|
|
||||||
startWatchers()
|
|
||||||
} else {
|
|
||||||
stopWatchers()
|
|
||||||
loading = false
|
|
||||||
// Optional: clear items to avoid stale UI
|
|
||||||
items = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: periodically refresh list so UI updates even if not in clip mode
|
|
||||||
Timer {
|
|
||||||
interval: 5000
|
|
||||||
repeat: true
|
|
||||||
running: root.active && root.cliphistAvailable
|
|
||||||
onTriggered: list()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Internal process objects
|
|
||||||
Process {
|
|
||||||
id: listProc
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
const out = String(stdout.text)
|
|
||||||
const lines = out.split('\n').filter(l => l.length > 0)
|
|
||||||
// cliphist list default format: "<id> <preview>" or "<id>\t<preview>"
|
|
||||||
const parsed = lines.map(l => {
|
|
||||||
let id = ""
|
|
||||||
let preview = ""
|
|
||||||
const m = l.match(/^(\d+)\s+(.+)$/)
|
|
||||||
if (m) {
|
|
||||||
id = m[1]
|
|
||||||
preview = m[2]
|
|
||||||
} else {
|
|
||||||
const tab = l.indexOf('\t')
|
|
||||||
id = tab > -1 ? l.slice(0, tab) : l
|
|
||||||
preview = tab > -1 ? l.slice(tab + 1) : ""
|
|
||||||
}
|
|
||||||
const lower = preview.toLowerCase()
|
|
||||||
const isImage = lower.startsWith("[image]") || lower.includes(" binary data ")
|
|
||||||
// Best-effort mime guess from preview
|
|
||||||
var mime = "text/plain"
|
|
||||||
if (isImage) {
|
|
||||||
if (lower.includes(" png"))
|
|
||||||
mime = "image/png"
|
|
||||||
else if (lower.includes(" jpg") || lower.includes(" jpeg"))
|
|
||||||
mime = "image/jpeg"
|
|
||||||
else if (lower.includes(" webp"))
|
|
||||||
mime = "image/webp"
|
|
||||||
else if (lower.includes(" gif"))
|
|
||||||
mime = "image/gif"
|
|
||||||
else
|
|
||||||
mime = "image/*"
|
|
||||||
}
|
|
||||||
// Record first seen time for new ids (approximate copy time)
|
|
||||||
if (!root.firstSeenById[id]) {
|
|
||||||
root.firstSeenById[id] = Time.timestamp
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"id": id,
|
|
||||||
"preview": preview,
|
|
||||||
"isImage": isImage,
|
|
||||||
"mime": mime
|
|
||||||
}
|
|
||||||
})
|
|
||||||
items = parsed
|
|
||||||
loading = false
|
|
||||||
|
|
||||||
// Emit the signal for subscribers
|
|
||||||
root.listCompleted()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: decodeProc
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
const out = String(stdout.text)
|
|
||||||
if (root._decodeCallback) {
|
|
||||||
try {
|
|
||||||
root._decodeCallback(out)
|
|
||||||
} finally {
|
|
||||||
root._decodeCallback = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: copyProc
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base64 decode pipeline (queued)
|
|
||||||
Process {
|
|
||||||
id: decodeB64Proc
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
const b64 = String(stdout.text).trim()
|
|
||||||
if (root._b64CurrentCb) {
|
|
||||||
const url = `data:${root._b64CurrentMime};base64,${b64}`
|
|
||||||
try {
|
|
||||||
root._b64CurrentCb(url)
|
|
||||||
} finally {
|
|
||||||
|
|
||||||
/* noop */ }
|
|
||||||
}
|
|
||||||
if (root._b64CurrentId !== "") {
|
|
||||||
root.imageDataById[root._b64CurrentId] = `data:${root._b64CurrentMime};base64,${b64}`
|
|
||||||
root.revision += 1
|
|
||||||
}
|
|
||||||
root._b64CurrentCb = null
|
|
||||||
root._b64CurrentMime = ""
|
|
||||||
root._b64CurrentId = ""
|
|
||||||
Qt.callLater(root._startNextB64)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Long-running watchers to store new clipboard contents
|
|
||||||
Process {
|
|
||||||
id: watchText
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
// Auto-restart if watcher dies
|
|
||||||
if (root.autoWatch)
|
|
||||||
Qt.callLater(() => {
|
|
||||||
running = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Process {
|
|
||||||
id: watchImage
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
if (root.autoWatch)
|
|
||||||
Qt.callLater(() => {
|
|
||||||
running = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startWatchers() {
|
|
||||||
if (!root.active || !autoWatch || watchersStarted || !root.cliphistAvailable)
|
|
||||||
return
|
|
||||||
watchersStarted = true
|
|
||||||
// Start text watcher
|
|
||||||
watchText.command = ["wl-paste", "--type", "text", "--watch", "cliphist", "store"]
|
|
||||||
watchText.running = true
|
|
||||||
// Start image watcher
|
|
||||||
watchImage.command = ["wl-paste", "--type", "image", "--watch", "cliphist", "store"]
|
|
||||||
watchImage.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopWatchers() {
|
|
||||||
if (!watchersStarted)
|
|
||||||
return
|
|
||||||
watchText.running = false
|
|
||||||
watchImage.running = false
|
|
||||||
watchersStarted = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function list(maxPreviewWidth) {
|
|
||||||
if (!root.active || !root.cliphistAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (listProc.running)
|
|
||||||
return
|
|
||||||
loading = true
|
|
||||||
const width = maxPreviewWidth || 100
|
|
||||||
listProc.command = ["cliphist", "list", "-preview-width", String(width)]
|
|
||||||
listProc.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function decode(id, cb) {
|
|
||||||
if (!root.cliphistAvailable) {
|
|
||||||
if (cb)
|
|
||||||
cb("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
root._decodeCallback = cb
|
|
||||||
decodeProc.command = ["cliphist", "decode", id]
|
|
||||||
decodeProc.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeToDataUrl(id, mime, cb) {
|
|
||||||
if (!root.cliphistAvailable) {
|
|
||||||
if (cb)
|
|
||||||
cb("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// If cached, return immediately
|
|
||||||
if (root.imageDataById[id]) {
|
|
||||||
if (cb)
|
|
||||||
cb(root.imageDataById[id])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Queue request; ensures single process handles sequentially
|
|
||||||
root._b64Queue.push({
|
|
||||||
"id": id,
|
|
||||||
"mime": mime || "image/*",
|
|
||||||
"cb": cb
|
|
||||||
})
|
|
||||||
if (!decodeB64Proc.running && root._b64CurrentCb === null) {
|
|
||||||
_startNextB64()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getImageData(id) {
|
|
||||||
if (id === undefined) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return root.imageDataById[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
function _startNextB64() {
|
|
||||||
if (root._b64Queue.length === 0 || !root.cliphistAvailable)
|
|
||||||
return
|
|
||||||
const job = root._b64Queue.shift()
|
|
||||||
root._b64CurrentCb = job.cb
|
|
||||||
root._b64CurrentMime = job.mime
|
|
||||||
root._b64CurrentId = job.id
|
|
||||||
decodeB64Proc.command = ["sh", "-lc", `cliphist decode ${job.id} | base64 -w 0`]
|
|
||||||
decodeB64Proc.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyToClipboard(id) {
|
|
||||||
if (!root.cliphistAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// decode and pipe to wl-copy; implement via shell to preserve binary
|
|
||||||
copyProc.command = ["sh", "-lc", `cliphist decode ${id} | wl-copy`]
|
|
||||||
copyProc.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteById(id) {
|
|
||||||
if (!root.cliphistAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Quickshell.execDetached(["cliphist", "delete", id])
|
|
||||||
Qt.callLater(() => list())
|
|
||||||
}
|
|
||||||
|
|
||||||
function wipeAll() {
|
|
||||||
if (!root.cliphistAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Quickshell.execDetached(["cliphist", "wipe"])
|
|
||||||
|
|
||||||
revision++
|
|
||||||
|
|
||||||
Qt.callLater(() => list())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue