226 lines
6.7 KiB
QML
226 lines
6.7 KiB
QML
pragma Singleton
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import qs.Commons
|
|
import qs.Services
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
property var history: []
|
|
property bool initialized: false
|
|
property int maxHistory: 50 // Limit clipboard history entries
|
|
|
|
// Internal state
|
|
property bool _enabled: true
|
|
|
|
// Cached history file path
|
|
property string historyFile: Quickshell.env("NOCTALIA_CLIPBOARD_HISTORY_FILE")
|
|
|| (Settings.cacheDir + "clipboard.json")
|
|
|
|
// Persisted storage for clipboard history
|
|
property FileView historyFileView: FileView {
|
|
id: historyFileView
|
|
objectName: "clipboardHistoryFileView"
|
|
path: historyFile
|
|
watchChanges: false // We don't need to watch changes for clipboard
|
|
onAdapterUpdated: writeAdapter()
|
|
Component.onCompleted: reload()
|
|
onLoaded: loadFromHistory()
|
|
onLoadFailed: function (error) {
|
|
// Create file on first use
|
|
if (error.toString().includes("No such file") || error === 2) {
|
|
writeAdapter()
|
|
}
|
|
}
|
|
|
|
JsonAdapter {
|
|
id: historyAdapter
|
|
property var history: []
|
|
property double timestamp: 0
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
interval: 2000
|
|
repeat: true
|
|
running: root._enabled
|
|
onTriggered: root.refresh()
|
|
}
|
|
|
|
// Detect current clipboard types (text/image)
|
|
Process {
|
|
id: typeProcess
|
|
property bool isLoading: false
|
|
property var currentTypes: []
|
|
|
|
onExited: (exitCode, exitStatus) => {
|
|
if (exitCode === 0) {
|
|
currentTypes = String(stdout.text).trim().split('\n').filter(t => t)
|
|
|
|
// Always check for text first
|
|
textProcess.command = ["wl-paste", "-n", "--type", "text/plain"]
|
|
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 {
|
|
typeProcess.isLoading = false
|
|
}
|
|
}
|
|
|
|
stdout: StdioCollector {}
|
|
}
|
|
|
|
// Read image data
|
|
Process {
|
|
id: imageProcess
|
|
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 {}
|
|
}
|
|
|
|
// Read text data
|
|
Process {
|
|
id: textProcess
|
|
property bool isLoading: false
|
|
|
|
onExited: (exitCode, exitStatus) => {
|
|
textProcess.isLoading = false
|
|
|
|
if (exitCode === 0) {
|
|
const content = String(stdout.text).trim()
|
|
if (content && content.length > 0) {
|
|
const entry = {
|
|
"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
|
|
root.initialized = true
|
|
if (!imageProcess.running) {
|
|
typeProcess.isLoading = false
|
|
}
|
|
}
|
|
|
|
stdout: StdioCollector {}
|
|
}
|
|
|
|
function refresh() {
|
|
if (!typeProcess.isLoading && !textProcess.isLoading && !imageProcess.running) {
|
|
typeProcess.isLoading = true
|
|
typeProcess.command = ["wl-paste", "-l"]
|
|
typeProcess.running = true
|
|
}
|
|
}
|
|
|
|
function loadFromHistory() {
|
|
// Populate in-memory history from cached file
|
|
try {
|
|
const items = historyAdapter.history || []
|
|
root.history = items.slice(0, maxHistory) // Apply limit when loading
|
|
Logger.log("Clipboard", "Loaded", root.history.length, "entries from cache")
|
|
} catch (e) {
|
|
Logger.error("Clipboard", "Failed to load history:", e)
|
|
root.history = []
|
|
}
|
|
}
|
|
|
|
function saveHistory() {
|
|
try {
|
|
// Ensure we don't exceed the maximum history limit
|
|
const limitedHistory = root.history.slice(0, maxHistory)
|
|
|
|
historyAdapter.history = limitedHistory
|
|
historyAdapter.timestamp = Time.timestamp
|
|
|
|
// Ensure cache directory exists
|
|
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir])
|
|
|
|
Qt.callLater(function () {
|
|
historyFileView.writeAdapter()
|
|
})
|
|
} catch (e) {
|
|
Logger.error("Clipboard", "Failed to save history:", e)
|
|
}
|
|
}
|
|
|
|
function clearHistory() {
|
|
root.history = []
|
|
saveHistory()
|
|
}
|
|
}
|