WIP: show stack in collection rail

This commit is contained in:
Akshay Kolli
2026-06-30 02:19:03 -07:00
parent 7f41127431
commit 3c4e4741d6
4 changed files with 151 additions and 2 deletions

View File

@@ -56,6 +56,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
private let collectionScrollView = NSScrollView()
private let collectionStack = NSStackView()
private let addCollectionButton = NSButton()
private let stackChip = CollectionChipView(title: "Stack", color: .systemGreen)
private let itemsStack = NSStackView()
private let scrollView = NSScrollView()
private let statusLabel = NSTextField(labelWithString: "")
@@ -326,6 +327,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
viewModel.onStackChanged = { [weak self] in
self?.reloadItems()
self?.updateSelection()
self?.configureCollectionButtons()
self?.updateStatus(self?.viewModel.statusMessage ?? "")
}
viewModel.onCaptureStatusChanged = { [weak self] in
@@ -390,11 +392,22 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
customCollectionButtons[collectionName] = chip
collectionStack.addArrangedSubview(chip)
}
configureStackChip()
collectionStack.addArrangedSubview(addCollectionButton)
updateAddCollectionButtonState()
sizeCollectionDocument()
}
private func configureStackChip() {
stackChip.toolTip = "Queued clips"
stackChip.onPress = { [weak self] in
self?.viewModel.selectStack()
}
if viewModel.stackCount > 0 {
collectionStack.addArrangedSubview(stackChip)
}
}
private func configureAddCollectionButton() {
let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "New collection")
image?.isTemplate = true
@@ -845,6 +858,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
chip.setSelected(viewModel.selectedCollectionName == name)
chip.setCount(viewModel.collectionCount(named: name))
}
stackChip.setSelected(viewModel.isStackFilterSelected)
stackChip.setCount(viewModel.stackCount)
sizeCollectionDocument()
}
@@ -1028,7 +1043,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
}
var debugSelectedCollectionTitle: String? {
collectionButtons.first(where: { $0.value.isSelected })?.value.titleText
if stackChip.isSelected {
return stackChip.titleText
}
return collectionButtons.first(where: { $0.value.isSelected })?.value.titleText
}
var debugCollectionCounts: [Int] {
@@ -1045,6 +1063,23 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
return viewModel.collectionNames.compactMap { customCollectionButtons[$0]?.count }
}
var debugStackChipIsVisible: Bool {
collectionStack.arrangedSubviews.contains(stackChip)
}
var debugStackChipCount: Int {
updateCollectionButtons()
return stackChip.count
}
var debugStackChipIsSelected: Bool {
stackChip.isSelected
}
func debugPressStackChip() {
stackChip.onPress()
}
var debugCollectionRailVisibleWidth: CGFloat {
collectionScrollView.contentView.bounds.width
}

View File

@@ -15,6 +15,7 @@ final class ClipboardPanelViewModel {
var sortMode: ClipboardSortMode {
didSet {
guard oldValue != sortMode else { return }
isStackFilterSelected = false
selectedCollectionName = nil
settings.defaultSortMode = sortMode
recomputeVisibleItems()
@@ -28,6 +29,13 @@ final class ClipboardPanelViewModel {
onCollectionsChanged?()
}
}
private(set) var isStackFilterSelected = false {
didSet {
guard oldValue != isStackFilterSelected else { return }
recomputeVisibleItems()
onStackChanged?()
}
}
var selectedIndex: Int = 0 {
didSet {
guard oldValue != selectedIndex else { return }
@@ -96,6 +104,10 @@ final class ClipboardPanelViewModel {
stackItemIDs.count
}
var stackTitle: String {
"Stack"
}
var collectionNames: [String] {
let assignedNames = Set(
items.compactMap { item -> String? in
@@ -216,12 +228,24 @@ final class ClipboardPanelViewModel {
statusMessage = "Added to Stack"
}
func selectStack() {
guard !stackItemIDs.isEmpty else { return }
selectedCollectionName = nil
isStackFilterSelected = true
}
func clearStackSelection() {
guard isStackFilterSelected else { return }
isStackFilterSelected = false
}
func clearStack() {
guard !stackItemIDs.isEmpty else {
statusMessage = "Stack is empty"
return
}
stackItemIDs.removeAll()
isStackFilterSelected = false
statusMessage = "Cleared Stack"
}
@@ -363,6 +387,7 @@ final class ClipboardPanelViewModel {
func selectCollection(named name: String) {
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return }
isStackFilterSelected = false
selectedCollectionName = normalizedName
}
@@ -374,7 +399,14 @@ final class ClipboardPanelViewModel {
pruneStackItems()
let previousSelection = selectedItemID
let query = searchText.clipboardTrimmed.lowercased()
visibleItems = computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: selectedCollectionName)
if isStackFilterSelected {
let stackedItems = stackItemIDs.compactMap { id in
items.first { $0.id == id }
}
visibleItems = computeStackVisibleItems(from: stackedItems, query: query)
} else {
visibleItems = computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: selectedCollectionName)
}
if let selectedID = previousSelection, let index = visibleItems.firstIndex(where: { $0.id == selectedID }) {
selectedIndex = index
@@ -432,6 +464,18 @@ final class ClipboardPanelViewModel {
if pruned != stackItemIDs {
stackItemIDs = pruned
}
if stackItemIDs.isEmpty && isStackFilterSelected {
isStackFilterSelected = false
}
}
private func computeStackVisibleItems(from items: [ClipboardItem], query: String) -> [ClipboardItem] {
let tokens = searchTokens(from: query.lowercased())
guard !tokens.isEmpty else { return items }
return items.filter { item in
let text = searchableText(for: item)
return tokens.allSatisfy { text.contains($0) }
}
}
internal func computeVisibleItems(

View File

@@ -367,6 +367,40 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.statusMessage, "Cleared Stack")
}
func testSelectingStackFiltersVisibleItemsInQueueOrder() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let first = makeTextItem("first queue note", createdAt: Date(timeIntervalSince1970: 100))
let second = makeTextItem("second queue note", createdAt: Date(timeIntervalSince1970: 200))
let outside = makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 300))
store.upsert(first)
store.upsert(second)
store.upsert(outside)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 3)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note", "second queue note", "first queue note"])
viewModel.selectItem(at: 2)
viewModel.toggleSelectedStackMembership()
viewModel.selectItem(at: 1)
viewModel.toggleSelectedStackMembership()
viewModel.selectStack()
XCTAssertTrue(viewModel.isStackFilterSelected)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["first queue note", "second queue note"])
viewModel.searchText = "second"
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["second queue note"])
viewModel.clearSearch()
viewModel.sortMode = .text
XCTAssertFalse(viewModel.isStackFilterSelected)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note", "second queue note", "first queue note"])
}
func testUpdateSelectedTextRefreshesVisibleItemAndSearch() {
let settings = makeSettings()
let cacheService = makeCacheService()

View File

@@ -370,6 +370,42 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Remove from Stack", "Edit", "Delete"])
}
func testStackChipAppearsFiltersAndClearsWithStack() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("First stack chip item", store: fixture.store))
fixture.store.upsert(makeTextItem("Second stack chip item", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertFalse(fixture.view.debugStackChipIsVisible)
fixture.viewModel.selectItem(at: 1)
fixture.viewModel.toggleSelectedStackMembership()
fixture.viewModel.selectItem(at: 0)
fixture.viewModel.toggleSelectedStackMembership()
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertTrue(fixture.view.debugStackChipIsVisible)
XCTAssertEqual(fixture.view.debugStackChipCount, 2)
XCTAssertFalse(fixture.view.debugStackChipIsSelected)
fixture.view.debugPressStackChip()
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Stack")
XCTAssertTrue(fixture.view.debugStackChipIsSelected)
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["First stack chip item", "Second stack chip item"])
fixture.viewModel.clearStack()
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertFalse(fixture.view.debugStackChipIsVisible)
XCTAssertEqual(fixture.view.debugStackChipCount, 0)
}
func testCollectionMenuOffersExistingCustomCollections() {
let fixture = makePanelFixture()
var existing = makeTextItem("Existing client note", store: fixture.store)