Files
DankMaterialShell/quickshell/Modals/Clipboard/ClipboardContent.qml
T
Mario Moharandbbedward 9eaaed3203 feat(clipboard): clear only the filtered entries (#3235)
* feat(clipboard): clear only the filtered entries

With a search query or type filter active, the clear button deleted
every unpinned entry regardless of what the list was showing, so
pruning a subset meant deleting entries one by one from the context
menu.

When the recents list is filtered, the button now clears exactly the
entries it shows and the modal stays open on that filter; with no
filter the previous behaviour is untouched. A filter that matches
nothing does nothing at all, rather than falling back to clearing
everything. The tooltip says "Clear Filtered" while a filter is
active, and the confirmation names the count.

Deleting one id at a time would have cost a round trip and a full
state broadcast per entry, so this adds clipboard.deleteEntries: one
bolt transaction, one state update, and it skips pinned entries
server-side so a bulk delete can never take out saved items.

Fixes #3198

* fix(clipboard): add translator context and list deleteEntries in IPC help

Review follow-up.

The two strings this branch adds carried no translator context: the
"Clear Filtered" tooltip in ClipboardHeader and the confirmation body for
a filtered clear in ClipboardHistoryContent. Both now say where they
appear, and the second one names what %1 is, per CONTRIBUTING.

"Clear All" on the same line is deliberately left alone. It is an
existing shared term with four other call sites, so a context there would
attach a clipboard-specific comment to a term the printer tab also uses.

clipboard.deleteEntries was handled but absent from the IPC method
listing that `dms debug-srv` prints, which left it undiscoverable right
next to clipboard.deleteEntry.

Catalogs untouched.

* chore: re-trigger CI

The lint-and-test failure on 21cefbf is an unrelated flake:
TestStoreEntry_CorruptDBReturnsErrorNotPanic died with SIGBUS in the
bbolt mmap. That test comes from master (c68b7e5) and passes there;
this branch only adds TestDeleteEntries_*/TestHandleDeleteEntries_*
and touches manager.go additively.

* clipboard: survive mmap faults from corrupted db pages

---------

Co-authored-by: bbedward <[email protected]>
2026-08-31 10:04:26 -04:00

377 lines
14 KiB
QML

import QtQuick
import Quickshell
import qs.Common
import qs.Widgets
import qs.Services
Item {
id: clipboardContent
required property var modal
property var transientSurfaceTracker: null
property alias searchField: searchField
property alias clipboardListView: clipboardListView
readonly property var filterOptions: [I18n.tr("All"), I18n.tr("Text"), I18n.tr("Long Text"), I18n.tr("Image")]
readonly property var filterValues: ["all", "text", "long_text", "image"]
function closeFilterMenu() {
filterMenuLoader.active = false;
filterMenuLoader.active = true;
}
function showContextMenu(entry, sceneX, sceneY) {
const localPos = mapFromItem(null, sceneX, sceneY);
contextMenu.show(localPos.x, localPos.y, entry);
}
function contextEntryAtScreen(screenX, screenY) {
const host = modal.surfaceHost ?? null;
const hostX = host?.alignedX;
const hostY = host?.renderedAlignedY ?? host?.alignedY;
if (!isNaN(hostX) && !isNaN(hostY))
return contextEntryAtLocal(screenX - hostX, screenY - hostY);
const screenRef = host?.effectiveScreen ?? host?.screen ?? modal.Window?.window?.screen ?? null;
const globalOrigin = mapToGlobal(0, 0);
const screenOriginX = screenRef?.x || 0;
const screenOriginY = screenRef?.y || 0;
return contextEntryAtLocal(screenOriginX + screenX - globalOrigin.x, screenOriginY + screenY - globalOrigin.y);
}
function contextEntryAtLocal(localX, localY) {
const listView = modal.activeTab === "saved" ? savedListView : clipboardListView;
const entries = modal.activeTab === "saved" ? modal.pinnedEntries : modal.unpinnedEntries;
if (!listView.visible || !entries)
return null;
const listPos = mapToItem(listView, localX, localY);
if (listPos.x < 0 || listPos.x > listView.width || listPos.y < 0 || listPos.y > listView.height)
return null;
const index = listView.indexAt(listPos.x + listView.contentX, listPos.y + listView.contentY);
if (index < 0 || index >= entries.length)
return null;
return {
entry: entries[index],
x: localX,
y: localY
};
}
function closeContextMenu() {
contextMenu.hide();
}
readonly property bool contextMenuActive: contextMenu.renderActive
anchors.fill: parent
ClipboardContextMenu {
id: contextMenu
modal: clipboardContent.modal
parentHandler: clipboardContent
transientSurfaceTracker: clipboardContent.transientSurfaceTracker
}
Column {
id: headerColumn
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
focus: false
ClipboardHeader {
id: header
width: parent.width
recentsCount: modal.unpinnedEntries.length
savedCount: modal.pinnedEntries.length
showKeyboardHints: modal.showKeyboardHints
activeTab: modal.activeTab
pinnedCount: modal.pinnedCount
clearsFilteredOnly: modal.clearsFilteredOnly
onKeyboardHintsToggled: modal.showKeyboardHints = !modal.showKeyboardHints
onTabChanged: tabName => modal.activeTab = tabName
onClearAllClicked: modal.confirmClearAll()
onCloseClicked: modal.hide()
}
Item {
id: searchRow
width: parent.width
implicitHeight: searchField.height
DankTextField {
id: searchField
width: parent.width
rightAccessoryWidth: filterButton.width + Theme.spacingS
placeholderText: ""
leftIconName: "search"
showClearButton: true
focus: true
ignoreTabKeys: true
keyForwardTargets: [modal.modalFocusScope]
onTextChanged: {
modal.searchText = text;
modal.updateFilteredModel();
ClipboardService.selectedIndex = 0;
ClipboardService.keyboardNavigationActive = true;
Qt.callLater(function () {
clipboardListView.positionViewAtBeginning();
savedListView.positionViewAtBeginning();
});
}
Keys.onEscapePressed: function (event) {
modal.hide();
event.accepted = true;
}
Component.onCompleted: {
Qt.callLater(function () {
forceActiveFocus();
});
}
}
DankActionButton {
id: filterButton
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
iconName: "filter_list"
iconColor: modal.activeFilter !== "all" ? Theme.primary : Theme.surfaceText
backgroundColor: modal.activeFilter !== "all" ? Theme.primarySelected : Theme.withAlpha(Theme.primarySelected, 0)
tooltipText: I18n.tr("Filter by type", "Clipboard history type filter button tooltip")
onClicked: filterMenuLoader.item?.openDropdownMenu()
}
Loader {
id: filterMenuLoader
active: true
sourceComponent: filterMenuComponent
}
Component {
id: filterMenuComponent
DankDropdown {
showTrigger: false
popupAnchorItem: filterButton
popupWidth: 180
alignPopupRight: true
options: clipboardContent.filterOptions
currentValue: {
const idx = clipboardContent.filterValues.indexOf(clipboardContent.modal.activeFilter);
return idx >= 0 ? clipboardContent.filterOptions[idx] : clipboardContent.filterOptions[0];
}
onValueChanged: value => {
const idx = clipboardContent.filterOptions.indexOf(value);
if (idx >= 0) {
clipboardContent.modal.activeFilter = clipboardContent.filterValues[idx];
}
}
}
}
}
}
Item {
id: listContainer
anchors.top: headerColumn.bottom
anchors.topMargin: Theme.spacingM
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
anchors.bottomMargin: (modal.showKeyboardHints ? (ClipboardConstants.keyboardHintsHeight + Theme.spacingM * 2) : 0) + Theme.spacingXS
clip: true
DankListView {
id: clipboardListView
anchors.fill: parent
model: ScriptModel {
values: clipboardContent.modal.unpinnedEntries
objectProp: "id"
}
visible: modal.activeTab === "recents"
currentIndex: clipboardContent.modal ? clipboardContent.modal.selectedIndex : 0
spacing: Theme.spacingXS
interactive: true
flickDeceleration: 1500
maximumFlickVelocity: 2000
boundsBehavior: Flickable.DragAndOvershootBounds
boundsMovement: Flickable.FollowBoundsBehavior
pressDelay: 0
flickableDirection: Flickable.VerticalFlick
function ensureVisible(index) {
if (index < 0 || index >= count) {
return;
}
positionViewAtIndex(index, ListView.Contain);
}
onCurrentIndexChanged: {
if (clipboardContent.modal?.keyboardNavigationActive && currentIndex >= 0) {
ensureVisible(currentIndex);
}
}
StyledText {
text: clipboardContent.modal.clipboardAvailable ? I18n.tr("No recent clipboard entries found") : I18n.tr("Connecting to clipboard service...")
anchors.centerIn: parent
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
visible: clipboardContent.modal.unpinnedEntries.length === 0
}
delegate: ClipboardEntry {
required property int index
required property var modelData
width: clipboardListView.width
height: ClipboardConstants.itemHeight
entry: modelData
entryIndex: index + 1
itemIndex: index
isSelected: clipboardContent.modal?.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex
modal: clipboardContent.modal
listView: clipboardListView
onCopyRequested: clipboardContent.modal.copyEntry(modelData)
onPasteRequested: clipboardContent.modal.pasteEntry(modelData)
onDeleteRequested: clipboardContent.modal.deleteEntry(modelData)
onPinRequested: targetEntry => clipboardContent.modal.pinEntry(targetEntry)
onUnpinRequested: targetEntry => clipboardContent.modal.unpinEntry(targetEntry)
onEditRequested: clipboardContent.modal.editEntry(modelData)
onContextMenuRequested: (mouseX, mouseY) => clipboardContent.showContextMenu(modelData, mouseX, mouseY)
}
}
DankListView {
id: savedListView
anchors.fill: parent
model: ScriptModel {
values: clipboardContent.modal.pinnedEntries
objectProp: "id"
}
visible: modal.activeTab === "saved"
currentIndex: clipboardContent.modal ? clipboardContent.modal.selectedIndex : 0
spacing: Theme.spacingXS
interactive: true
flickDeceleration: 1500
maximumFlickVelocity: 2000
boundsBehavior: Flickable.DragAndOvershootBounds
boundsMovement: Flickable.FollowBoundsBehavior
pressDelay: 0
flickableDirection: Flickable.VerticalFlick
function ensureVisible(index) {
if (index < 0 || index >= count) {
return;
}
positionViewAtIndex(index, ListView.Contain);
}
onCurrentIndexChanged: {
if (clipboardContent.modal?.keyboardNavigationActive && currentIndex >= 0) {
ensureVisible(currentIndex);
}
}
StyledText {
text: clipboardContent.modal.clipboardAvailable ? I18n.tr("No saved clipboard entries") : I18n.tr("Connecting to clipboard service...")
anchors.centerIn: parent
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
visible: clipboardContent.modal.pinnedEntries.length === 0
}
delegate: ClipboardEntry {
required property int index
required property var modelData
width: savedListView.width
height: ClipboardConstants.itemHeight
entry: modelData
entryIndex: index + 1
itemIndex: index
isSelected: clipboardContent.modal?.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex
modal: clipboardContent.modal
listView: savedListView
onCopyRequested: clipboardContent.modal.copyEntry(modelData)
onPasteRequested: clipboardContent.modal.pasteEntry(modelData)
onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData)
onPinRequested: targetEntry => clipboardContent.modal.pinEntry(targetEntry)
onUnpinRequested: targetEntry => clipboardContent.modal.unpinEntry(targetEntry)
onEditRequested: clipboardContent.modal.editEntry(modelData)
onContextMenuRequested: (mouseX, mouseY) => clipboardContent.showContextMenu(modelData, mouseX, mouseY)
}
}
Rectangle {
id: bottomFade
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 24
z: 100
visible: {
const listView = modal.activeTab === "recents" ? clipboardListView : savedListView;
if (listView.contentHeight <= listView.height)
return false;
const atBottom = listView.contentY >= listView.contentHeight - listView.height - 5;
return !atBottom;
}
gradient: Gradient {
GradientStop {
position: 0.0
color: "transparent"
}
GradientStop {
position: 1.0
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
}
}
}
}
Loader {
id: keyboardHintsLoader
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
anchors.bottomMargin: active ? Theme.spacingM : 0
active: modal.showKeyboardHints
height: active ? ClipboardConstants.keyboardHintsHeight : 0
Behavior on height {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
sourceComponent: ClipboardKeyboardHints {
pasteAvailable: modal.pasteAvailable
enterToPaste: SettingsData.clipboardEnterToPaste
}
}
}