From 0922e3e4594ce50367edad7a3772a8ecb19a6f00 Mon Sep 17 00:00:00 2001 From: purian23 Date: Mon, 9 Feb 2026 20:53:48 -0500 Subject: [PATCH] clipboard: Fix pinned entry logic - Add keyboard nav to pinned entries - Fix wrong copied selection upon Enter --- core/internal/server/clipboard/handlers.go | 13 ++- core/internal/server/clipboard/manager.go | 93 ++++++++++++++++++- .../Modals/Clipboard/ClipboardContent.qml | 23 ++++- .../Modals/Clipboard/ClipboardEntry.qml | 5 +- .../Clipboard/ClipboardHistoryModal.qml | 8 ++ .../Clipboard/ClipboardKeyboardController.qml | 24 +++-- quickshell/Services/ClipboardService.qml | 7 ++ 7 files changed, 158 insertions(+), 15 deletions(-) diff --git a/core/internal/server/clipboard/handlers.go b/core/internal/server/clipboard/handlers.go index 2c6efb24..1ccc7104 100644 --- a/core/internal/server/clipboard/handlers.go +++ b/core/internal/server/clipboard/handlers.go @@ -146,9 +146,16 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) { return } - if err := m.TouchEntry(uint64(id)); err != nil { - models.RespondError(conn, req.ID, err.Error()) - return + if entry.Pinned { + if err := m.CreateHistoryEntryFromPinned(entry); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + } else { + if err := m.TouchEntry(uint64(id)); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } } models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"}) diff --git a/core/internal/server/clipboard/manager.go b/core/internal/server/clipboard/manager.go index dba84214..8d4603dc 100644 --- a/core/internal/server/clipboard/manager.go +++ b/core/internal/server/clipboard/manager.go @@ -388,6 +388,10 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error { if extractHash(v) != hash { continue } + entry, err := decodeEntry(v) + if err == nil && entry.Pinned { + continue + } if err := b.Delete(k); err != nil { return err } @@ -842,6 +846,62 @@ func (m *Manager) TouchEntry(id uint64) error { return nil } +func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error { + if m.db == nil { + return fmt.Errorf("database not available") + } + + // Create a new unpinned entry with the same data + newEntry := Entry{ + Data: pinnedEntry.Data, + MimeType: pinnedEntry.MimeType, + Size: pinnedEntry.Size, + Timestamp: time.Now(), + IsImage: pinnedEntry.IsImage, + Preview: pinnedEntry.Preview, + Pinned: false, + } + + if err := m.storeEntryWithoutDedup(newEntry); err != nil { + return err + } + + m.updateState() + m.notifySubscribers() + + return nil +} + +func (m *Manager) storeEntryWithoutDedup(entry Entry) error { + if m.db == nil { + return fmt.Errorf("database not available") + } + + entry.Hash = computeHash(entry.Data) + + return m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + + id, err := b.NextSequence() + if err != nil { + return err + } + + entry.ID = id + + encoded, err := encodeEntry(entry) + if err != nil { + return err + } + + if err := b.Put(itob(id), encoded); err != nil { + return err + } + + return m.trimLengthInTx(b) + }) +} + func (m *Manager) ClearHistory() { if m.db == nil { return @@ -1419,6 +1479,37 @@ func (m *Manager) PinEntry(id uint64) error { return fmt.Errorf("database not available") } + entryToPin, err := m.GetEntry(id) + if err != nil { + return err + } + + var hashExists bool + if err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + entry, err := decodeEntry(v) + if err != nil || !entry.Pinned { + continue + } + if entry.Hash == entryToPin.Hash { + hashExists = true + return nil + } + } + return nil + }); err != nil { + return err + } + + if hashExists { + return nil + } + // Check pinned count cfg := m.getConfig() pinnedCount := 0 @@ -1443,7 +1534,7 @@ func (m *Manager) PinEntry(id uint64) error { return fmt.Errorf("maximum pinned entries reached (%d)", cfg.MaxPinned) } - err := m.db.Update(func(tx *bolt.Tx) error { + err = m.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) v := b.Get(itob(id)) if v == nil { diff --git a/quickshell/Modals/Clipboard/ClipboardContent.qml b/quickshell/Modals/Clipboard/ClipboardContent.qml index 83db9794..8a4cfafd 100644 --- a/quickshell/Modals/Clipboard/ClipboardContent.qml +++ b/quickshell/Modals/Clipboard/ClipboardContent.qml @@ -164,6 +164,7 @@ Item { } visible: modal.activeTab === "saved" + currentIndex: clipboardContent.modal ? clipboardContent.modal.selectedIndex : 0 spacing: Theme.spacingXS interactive: true flickDeceleration: 1500 @@ -173,6 +174,26 @@ Item { pressDelay: 0 flickableDirection: Flickable.VerticalFlick + function ensureVisible(index) { + if (index < 0 || index >= count) { + return; + } + const itemHeight = ClipboardConstants.itemHeight + spacing; + const itemY = index * itemHeight; + const itemBottom = itemY + itemHeight; + if (itemY < contentY) { + contentY = itemY; + } else if (itemBottom > contentY + height) { + contentY = itemBottom - height; + } + } + + onCurrentIndexChanged: { + if (clipboardContent.modal?.keyboardNavigationActive && currentIndex >= 0) { + ensureVisible(currentIndex); + } + } + StyledText { text: I18n.tr("No saved clipboard entries") anchors.centerIn: parent @@ -190,7 +211,7 @@ Item { entry: modelData entryIndex: index + 1 itemIndex: index - isSelected: false + isSelected: clipboardContent.modal?.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex modal: clipboardContent.modal listView: savedListView onCopyRequested: clipboardContent.modal.copyEntry(modelData) diff --git a/quickshell/Modals/Clipboard/ClipboardEntry.qml b/quickshell/Modals/Clipboard/ClipboardEntry.qml index 61c87a7d..891038de 100644 --- a/quickshell/Modals/Clipboard/ClipboardEntry.qml +++ b/quickshell/Modals/Clipboard/ClipboardEntry.qml @@ -19,6 +19,7 @@ Rectangle { readonly property string entryType: modal ? modal.getEntryType(entry) : "text" readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : "" + readonly property bool hasPinnedDuplicate: !entry.pinned && modal ? modal.hashedPinnedEntry(entry.hash) : false radius: Theme.cornerRadius color: { @@ -111,8 +112,8 @@ Rectangle { DankActionButton { iconName: "push_pin" iconSize: Theme.iconSize - 6 - iconColor: entry.pinned ? Theme.primary : Theme.surfaceText - backgroundColor: entry.pinned ? Theme.primarySelected : "transparent" + iconColor: (entry.pinned || hasPinnedDuplicate) ? Theme.primary : Theme.surfaceText + backgroundColor: (entry.pinned || hasPinnedDuplicate) ? Theme.primarySelected : "transparent" onClicked: entry.pinned ? unpinRequested() : pinRequested() } diff --git a/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml b/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml index 9d422ddd..f5be5476 100644 --- a/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml +++ b/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml @@ -18,6 +18,10 @@ DankModal { } property string activeTab: "recents" + onActiveTabChanged: { + ClipboardService.selectedIndex = 0; + ClipboardService.keyboardNavigationActive = false; + } property bool showKeyboardHints: false property Component clipboardContent property int activeImageLoads: 0 @@ -121,6 +125,10 @@ DankModal { return ClipboardService.getEntryType(entry); } + function hashedPinnedEntry(entryHash) { + return ClipboardService.hashedPinnedEntry(entryHash); + } + visible: false modalWidth: ClipboardConstants.modalWidth modalHeight: ClipboardConstants.modalHeight diff --git a/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml b/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml index 3d6ebacb..da3addaf 100644 --- a/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml +++ b/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml @@ -13,15 +13,17 @@ QtObject { } function selectNext() { - if (!ClipboardService.clipboardEntries || ClipboardService.clipboardEntries.length === 0) { + const entries = modal.activeTab === "saved" ? ClipboardService.pinnedEntries : ClipboardService.unpinnedEntries; + if (!entries || entries.length === 0) { return; } ClipboardService.keyboardNavigationActive = true; - ClipboardService.selectedIndex = Math.min(ClipboardService.selectedIndex + 1, ClipboardService.clipboardEntries.length - 1); + ClipboardService.selectedIndex = Math.min(ClipboardService.selectedIndex + 1, entries.length - 1); } function selectPrevious() { - if (!ClipboardService.clipboardEntries || ClipboardService.clipboardEntries.length === 0) { + const entries = modal.activeTab === "saved" ? ClipboardService.pinnedEntries : ClipboardService.unpinnedEntries; + if (!entries || entries.length === 0) { return; } ClipboardService.keyboardNavigationActive = true; @@ -29,19 +31,25 @@ QtObject { } function copySelected() { - if (!ClipboardService.clipboardEntries || ClipboardService.clipboardEntries.length === 0 || ClipboardService.selectedIndex < 0 || ClipboardService.selectedIndex >= ClipboardService.clipboardEntries.length) { + const entries = modal.activeTab === "saved" ? ClipboardService.pinnedEntries : ClipboardService.unpinnedEntries; + if (!entries || entries.length === 0 || ClipboardService.selectedIndex < 0 || ClipboardService.selectedIndex >= entries.length) { return; } - const selectedEntry = ClipboardService.clipboardEntries[ClipboardService.selectedIndex]; + const selectedEntry = entries[ClipboardService.selectedIndex]; modal.copyEntry(selectedEntry); } function deleteSelected() { - if (!ClipboardService.clipboardEntries || ClipboardService.clipboardEntries.length === 0 || ClipboardService.selectedIndex < 0 || ClipboardService.selectedIndex >= ClipboardService.clipboardEntries.length) { + const entries = modal.activeTab === "saved" ? ClipboardService.pinnedEntries : ClipboardService.unpinnedEntries; + if (!entries || entries.length === 0 || ClipboardService.selectedIndex < 0 || ClipboardService.selectedIndex >= entries.length) { return; } - const selectedEntry = ClipboardService.clipboardEntries[ClipboardService.selectedIndex]; - modal.deleteEntry(selectedEntry); + const selectedEntry = entries[ClipboardService.selectedIndex]; + if (modal.activeTab === "saved") { + modal.deletePinnedEntry(selectedEntry); + } else { + modal.deleteEntry(selectedEntry); + } } function handleKey(event) { diff --git a/quickshell/Services/ClipboardService.qml b/quickshell/Services/ClipboardService.qml index 1d586a0c..19d4fd7f 100644 --- a/quickshell/Services/ClipboardService.qml +++ b/quickshell/Services/ClipboardService.qml @@ -248,6 +248,13 @@ Singleton { return "text"; } + function hashedPinnedEntry(entryHash) { + if (!entryHash) { + return false; + } + return pinnedEntries.some(pinnedEntry => pinnedEntry.hash === entryHash); + } + Connections { target: DMSService enabled: root.refCount > 0