From 5483303714a4faba60ce6b5b4bc520b3e969fd3f Mon Sep 17 00:00:00 2001 From: jbwfu <75001777+jbwfu@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:05:28 +0800 Subject: [PATCH] Fix/clipboard pinned recents dedupe (#2605) * fix(clipboard): unpin pinned duplicates from history entries * fix(clipboard): dedupe recents when using pinned entries --- core/internal/server/clipboard/manager.go | 63 +++++------ .../internal/server/clipboard/manager_test.go | 105 ++++++++++++++++++ .../Modals/Clipboard/ClipboardContent.qml | 8 +- .../Modals/Clipboard/ClipboardEntry.qml | 23 +++- .../Clipboard/ClipboardKeyboardController.qml | 7 +- quickshell/Services/ClipboardService.qml | 10 +- 6 files changed, 171 insertions(+), 45 deletions(-) diff --git a/core/internal/server/clipboard/manager.go b/core/internal/server/clipboard/manager.go index 69c65bc6..63449cc7 100644 --- a/core/internal/server/clipboard/manager.go +++ b/core/internal/server/clipboard/manager.go @@ -935,7 +935,7 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error { Pinned: false, } - if err := m.storeEntryWithoutDedup(newEntry); err != nil { + if err := m.storeEntry(newEntry); err != nil { return err } @@ -945,36 +945,6 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error { 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 @@ -1653,6 +1623,37 @@ func (m *Manager) UnpinEntry(id uint64) error { return err } + if entry.Pinned { + currentKey := itob(id) + var keepKey []byte + var deleteKeys [][]byte + + c := b.Cursor() + for k, v := c.Last(); k != nil; k, v = c.Prev() { + if bytes.Equal(k, currentKey) || extractHash(v) != entry.Hash { + continue + } + duplicate, err := decodeEntryMeta(v) + if err == nil && !duplicate.Pinned { + key := append([]byte(nil), k...) + if keepKey == nil { + keepKey = key + } else { + deleteKeys = append(deleteKeys, key) + } + } + } + + if keepKey != nil { + for _, key := range deleteKeys { + if err := b.Delete(key); err != nil { + return err + } + } + return b.Delete(currentKey) + } + } + entry.Pinned = false encoded, err := encodeEntry(entry) if err != nil { diff --git a/core/internal/server/clipboard/manager_test.go b/core/internal/server/clipboard/manager_test.go index 372babe6..cc231f8a 100644 --- a/core/internal/server/clipboard/manager_test.go +++ b/core/internal/server/clipboard/manager_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + bolt "go.etcd.io/bbolt" mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext" ) @@ -273,6 +274,110 @@ func TestHandleGetEntry_MissingIDReturnsNullResult(t *testing.T) { assert.Nil(t, resp.Result) } +func TestUnpinEntry_KeepsTopUnpinnedDuplicate(t *testing.T) { + m := newTestManagerWithDB(t) + + require.NoError(t, m.storeEntry(Entry{ + Data: []byte("saved content"), + MimeType: "text/plain;charset=utf-8", + Preview: "saved content", + Size: len("saved content"), + Timestamp: time.Now().Add(-time.Minute).Truncate(time.Second), + IsImage: false, + })) + + history := m.GetHistory() + require.Len(t, history, 1) + pinnedID := history[0].ID + require.NoError(t, m.PinEntry(pinnedID)) + + pinnedEntry, err := m.GetEntry(pinnedID) + require.NoError(t, err) + require.True(t, pinnedEntry.Pinned) + + // Bypass storeEntry to simulate legacy duplicate ordinary history entries. + insertLegacyUnpinnedDuplicate := func(timestamp time.Time) Entry { + duplicate := Entry{ + Data: pinnedEntry.Data, + MimeType: pinnedEntry.MimeType, + Preview: pinnedEntry.Preview, + Size: pinnedEntry.Size, + Timestamp: timestamp, + IsImage: pinnedEntry.IsImage, + Pinned: false, + } + duplicate.Hash = computeHash(duplicate.Data) + + require.NoError(t, m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + id, err := b.NextSequence() + if err != nil { + return err + } + duplicate.ID = id + + encoded, err := encodeEntry(duplicate) + if err != nil { + return err + } + return b.Put(itob(id), encoded) + })) + + return duplicate + } + + olderHistoryDuplicate := insertLegacyUnpinnedDuplicate(time.Now().Add(time.Hour)) + topHistoryDuplicate := insertLegacyUnpinnedDuplicate(time.Now().Add(-time.Hour)) + require.Greater(t, topHistoryDuplicate.ID, olderHistoryDuplicate.ID) + require.True(t, olderHistoryDuplicate.Timestamp.After(topHistoryDuplicate.Timestamp)) + + history = m.GetHistory() + require.Len(t, history, 3) + require.Equal(t, topHistoryDuplicate.ID, history[0].ID) + require.NoError(t, m.UnpinEntry(pinnedID)) + + history = m.GetHistory() + require.Len(t, history, 1) + assert.False(t, history[0].Pinned) + assert.Equal(t, pinnedEntry.Hash, history[0].Hash) + assert.Equal(t, topHistoryDuplicate.ID, history[0].ID) +} + +func TestCreateHistoryEntryFromPinned_KeepsLatestUnpinnedDuplicate(t *testing.T) { + m := newTestManagerWithDB(t) + + require.NoError(t, m.storeEntry(Entry{ + Data: []byte("saved content"), + MimeType: "text/plain;charset=utf-8", + Preview: "saved content", + Size: len("saved content"), + Timestamp: time.Now().Add(-time.Minute).Truncate(time.Second), + IsImage: false, + })) + + history := m.GetHistory() + require.Len(t, history, 1) + pinnedID := history[0].ID + require.NoError(t, m.PinEntry(pinnedID)) + + pinnedEntry, err := m.GetEntry(pinnedID) + require.NoError(t, err) + require.True(t, pinnedEntry.Pinned) + require.NoError(t, m.CreateHistoryEntryFromPinned(pinnedEntry)) + firstDuplicate := m.GetHistory()[0] + require.NotEqual(t, pinnedID, firstDuplicate.ID) + require.NoError(t, m.CreateHistoryEntryFromPinned(pinnedEntry)) + latestDuplicate := m.GetHistory()[0] + + history = m.GetHistory() + require.Len(t, history, 2) + assert.Equal(t, latestDuplicate.ID, history[0].ID) + assert.False(t, history[0].Pinned) + assert.Equal(t, pinnedID, history[1].ID) + assert.True(t, history[1].Pinned) + assert.NotEqual(t, firstDuplicate.ID, latestDuplicate.ID) +} + func TestManager_ConcurrentSubscriberAccess(t *testing.T) { m := &Manager{ subscribers: make(map[string]chan State), diff --git a/quickshell/Modals/Clipboard/ClipboardContent.qml b/quickshell/Modals/Clipboard/ClipboardContent.qml index 48a38455..4557698d 100644 --- a/quickshell/Modals/Clipboard/ClipboardContent.qml +++ b/quickshell/Modals/Clipboard/ClipboardContent.qml @@ -149,8 +149,8 @@ Item { listView: clipboardListView onCopyRequested: clipboardContent.modal.copyEntry(modelData) onDeleteRequested: clipboardContent.modal.deleteEntry(modelData) - onPinRequested: clipboardContent.modal.pinEntry(modelData) - onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) + onPinRequested: targetEntry => clipboardContent.modal.pinEntry(targetEntry) + onUnpinRequested: targetEntry => clipboardContent.modal.unpinEntry(targetEntry) onEditRequested: clipboardContent.modal.editEntry(modelData) } } @@ -223,8 +223,8 @@ Item { listView: savedListView onCopyRequested: clipboardContent.modal.copyEntry(modelData) onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData) - onPinRequested: clipboardContent.modal.pinEntry(modelData) - onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) + onPinRequested: targetEntry => clipboardContent.modal.pinEntry(targetEntry) + onUnpinRequested: targetEntry => clipboardContent.modal.unpinEntry(targetEntry) onEditRequested: clipboardContent.modal.editEntry(modelData) } } diff --git a/quickshell/Modals/Clipboard/ClipboardEntry.qml b/quickshell/Modals/Clipboard/ClipboardEntry.qml index 5f57bc48..8cd7811a 100644 --- a/quickshell/Modals/Clipboard/ClipboardEntry.qml +++ b/quickshell/Modals/Clipboard/ClipboardEntry.qml @@ -15,13 +15,14 @@ Rectangle { signal copyRequested signal deleteRequested - signal pinRequested - signal unpinRequested + signal pinRequested(var targetEntry) + signal unpinRequested(var targetEntry) signal editRequested readonly property string entryType: modal ? modal.getEntryType(entry) : "text" readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : "" - readonly property bool hasPinnedDuplicate: !entry.pinned && ClipboardService.hashedPinnedEntry(entry.hash) + readonly property var pinnedDuplicateEntry: !entry.pinned ? ClipboardService.getPinnedEntryByHash(entry.hash) : null + readonly property bool effectivePinned: entry.pinned || pinnedDuplicateEntry !== null radius: Theme.cornerRadius color: { @@ -66,9 +67,19 @@ Rectangle { DankActionButton { iconName: "push_pin" iconSize: Theme.iconSize - 6 - iconColor: (entry.pinned || hasPinnedDuplicate) ? Theme.primary : Theme.surfaceText - backgroundColor: (entry.pinned || hasPinnedDuplicate) ? Theme.primarySelected : "transparent" - onClicked: entry.pinned ? unpinRequested() : pinRequested() + iconColor: effectivePinned ? Theme.primary : Theme.surfaceText + backgroundColor: effectivePinned ? Theme.primarySelected : "transparent" + onClicked: { + if (entry.pinned) { + unpinRequested(entry); + return; + } + if (pinnedDuplicateEntry) { + unpinRequested(pinnedDuplicateEntry); + return; + } + pinRequested(entry); + } } DankActionButton { diff --git a/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml b/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml index 1798426b..78a757c9 100644 --- a/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml +++ b/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml @@ -59,8 +59,13 @@ QtObject { return; } const selectedEntry = entries[ClipboardService.selectedIndex]; - if (modal.activeTab === "saved") { + if (selectedEntry.pinned) { modal.unpinEntry(selectedEntry); + return; + } + const pinnedDuplicate = ClipboardService.getPinnedEntryByHash(selectedEntry.hash); + if (pinnedDuplicate) { + modal.unpinEntry(pinnedDuplicate); } else { modal.pinEntry(selectedEntry); } diff --git a/quickshell/Services/ClipboardService.qml b/quickshell/Services/ClipboardService.qml index 93296385..5d01a46e 100644 --- a/quickshell/Services/ClipboardService.qml +++ b/quickshell/Services/ClipboardService.qml @@ -388,11 +388,15 @@ Singleton { return "text"; } - function hashedPinnedEntry(entryHash) { + function getPinnedEntryByHash(entryHash) { if (!entryHash) { - return false; + return null; } - return pinnedEntries.some(pinnedEntry => pinnedEntry.hash === entryHash); + return internalEntries.find(entry => entry.pinned && entry.hash === entryHash) || null; + } + + function hashedPinnedEntry(entryHash) { + return getPinnedEntryByHash(entryHash) !== null; } onClipboardAvailableChanged: {