Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,14 @@ jobs:
-configuration Debug \
CODE_SIGNING_ALLOWED=NO \
build

- name: Test panel tab shortcuts
run: |
xcodebuild \
-project Clipbara.xcodeproj \
-scheme ClipbaraTests \
-configuration Debug \
-destination 'platform=macOS' \
-parallel-testing-enabled NO \
CODE_SIGNING_ALLOWED=NO \
test
7 changes: 2 additions & 5 deletions Clipbara/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@ import SwiftUI
import SwiftData
import KeyboardShortcuts

enum PanelTab: Equatable, Hashable {
case history
case pinboard(UUID)
}

struct PanelToast: Identifiable, Equatable {
let id = UUID()
let message: String
Expand All @@ -22,6 +17,8 @@ final class AppState {
let searchState = SearchState()

var selectedTab: PanelTab = .history
/// Published by NavigationBarView so shortcuts follow its exact display order.
var orderedPinboardIDs: [UUID] = []
var previewItem: ClipboardItem?
var panelToast: PanelToast?
var panelPresentationID = 0
Expand Down
31 changes: 29 additions & 2 deletions Clipbara/Panel/PanelController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,19 @@ final class PanelController {
return false
}

// MARK: - Key Monitor (arrow keys, space, esc, return)
/// Shared by mouse tabs and Cmd+number. Clear the old navigation cache
/// synchronously so a fast Return cannot paste an item from the old tab
/// while SwiftUI is still rendering the new one.
func selectTab(_ tab: PanelTab) {
guard let appState, isVisible, appState.selectedTab != tab else { return }
if quickLookPanel != nil { hideQuickLook() }
appState.selectForPreview(nil)
appState.searchState.selectedIndex = nil
appState.currentFilteredItems = []
appState.selectedTab = tab
}

// MARK: - Key Monitor (tab shortcuts, arrow keys, space, esc, return)

private func installKeyMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
Expand All @@ -322,6 +334,21 @@ final class PanelController {
return false
}

// Never navigate behind a create/rename/delete sheet or modal.
guard self.panel?.attachedSheet == nil,
self.quickLookPanel?.attachedSheet == nil,
NSApp.modalWindow == nil else { return false }

// Handle tab shortcuts before the search-field pass-through.
// Missing tabs are a no-op, not a shortcut for the frontmost app.
if let index = PanelTabShortcut.index(keyCode: keyCode, modifiers: event.modifierFlags) {
if let appState = self.appState,
let tab = PanelTabShortcut.target(at: index, pinboardIDs: appState.orderedPinboardIDs) {
self.selectTab(tab)
}
return true
}

if self.quickLookPanel != nil {
return self.processKey(keyCode)
}
Expand Down Expand Up @@ -383,7 +410,7 @@ final class PanelController {
return true
}
if appState.selectedTab != .history {
appState.selectedTab = .history
selectTab(.history)
return true
}
appState.hidePanel()
Expand Down
33 changes: 33 additions & 0 deletions Clipbara/Utilities/PanelTabShortcut.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import AppKit

enum PanelTab: Equatable, Hashable {
case history
case pinboard(UUID)
}

/// Fixed panel-local shortcuts, in the same order as the visible tabs.
/// No global hotkeys are registered for tab navigation.
enum PanelTabShortcut {
static func index(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Int? {
let chord = modifiers.intersection([.command, .option, .control, .shift])
guard chord == .command else { return nil }

// Physical number row and numeric keypad, 1 through 9. Caps Lock and
// the keypad flag do not change the shortcut; extra modifiers do.
let numberRow: [UInt16] = [18, 19, 20, 21, 23, 22, 26, 28, 25]
let keypad: [UInt16] = [83, 84, 85, 86, 87, 88, 89, 91, 92]
return numberRow.firstIndex(of: keyCode) ?? keypad.firstIndex(of: keyCode)
}

static func target(at index: Int, pinboardIDs: [UUID]) -> PanelTab? {
guard (0..<9).contains(index) else { return nil }
if index == 0 { return .history }
guard pinboardIDs.indices.contains(index - 1) else { return nil }
return .pinboard(pinboardIDs[index - 1])
}

static func hint(at index: Int) -> String? {
guard (0..<9).contains(index) else { return nil }
return "⌘\(index + 1)"
}
}
93 changes: 56 additions & 37 deletions Clipbara/Views/NavigationBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ struct NavigationBarView: View {
var body: some View {
navigationBar
.frame(height: DesignTokens.Nav.height)
.onAppear { appState.orderedPinboardIDs = pinboards.map(\.id) }
.onChange(of: pinboards.map(\.id)) { _, ids in
appState.orderedPinboardIDs = ids
}
.alert("Create Pinboard", isPresented: $isAddingPinboard) {
TextField("Name", text: $newPinboardName)
Button("Cancel", role: .cancel) { newPinboardName = "" }
Expand Down Expand Up @@ -98,49 +102,60 @@ struct NavigationBarView: View {
}

private var tabGroup: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
navTab(
label: "History",
icon: "clock",
isActive: appState.selectedTab == .history
) {
appState.selectedTab = .history
}

if !pinboards.isEmpty {
Divider()
.frame(height: 18)
.padding(.horizontal, 2)
}

ForEach(pinboards) { pinboard in
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
navTab(
label: pinboard.name,
icon: "folder",
isActive: appState.selectedTab == .pinboard(pinboard.id),
isDropTargeted: targetedPinboardID == pinboard.id
label: "History",
icon: "clock",
isActive: appState.selectedTab == .history
) {
appState.selectedTab = .pinboard(pinboard.id)
appState.panelController.selectTab(.history)
}
.onDrop(
of: [.pasteClipClipboardItemID, .text, .url, .fileURL, .image, .data, .item],
isTargeted: dropTargetBinding(for: pinboard.id)
) { providers in
addDroppedClip(from: providers, to: pinboard.id)
.id(PanelTab.history)
.help("History (⌘1)")

if !pinboards.isEmpty {
Divider()
.frame(height: 18)
.padding(.horizontal, 2)
}
.contextMenu {
Button("Rename Pinboard") {
renameText = pinboard.name
renamingPinboard = pinboard

ForEach(Array(pinboards.enumerated()), id: \.element.id) { index, pinboard in
navTab(
label: pinboard.name,
icon: "folder",
isActive: appState.selectedTab == .pinboard(pinboard.id),
isDropTargeted: targetedPinboardID == pinboard.id
) {
appState.panelController.selectTab(.pinboard(pinboard.id))
}
Divider()
Button("Delete Pinboard", role: .destructive) {
deletingPinboard = pinboard
.id(PanelTab.pinboard(pinboard.id))
.help(PanelTabShortcut.hint(at: index + 1).map { "\(pinboard.name) (\($0))" } ?? pinboard.name)
.onDrop(
of: [.pasteClipClipboardItemID, .text, .url, .fileURL, .image, .data, .item],
isTargeted: dropTargetBinding(for: pinboard.id)
) { providers in
addDroppedClip(from: providers, to: pinboard.id)
}
.contextMenu {
Button("Rename Pinboard") {
renameText = pinboard.name
renamingPinboard = pinboard
}
Divider()
Button("Delete Pinboard", role: .destructive) {
deletingPinboard = pinboard
}
}
}
}
}
.onChange(of: appState.selectedTab) { _, tab in
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo(tab, anchor: .center)
}
}
}
}

Expand Down Expand Up @@ -274,11 +289,14 @@ struct NavigationBarView: View {
private func createPinboard() {
let trimmed = newPinboardName.trimmingCharacters(in: .whitespaces)
let name = trimmed.isEmpty ? nextPinboardName() : uniquePinboardName(preferred: trimmed)
let pinboard = Pinboard(name: name, displayOrder: pinboards.count)
let nextOrder = (pinboards.map(\.displayOrder).max() ?? -1) + 1
let pinboard = Pinboard(name: name, displayOrder: nextOrder)
modelContext.insert(pinboard)
try? modelContext.save()
newPinboardName = ""
appState.selectedTab = .pinboard(pinboard.id)
// Keep shortcut positions current until @Query publishes the insert.
appState.orderedPinboardIDs = pinboards.filter { $0.id != pinboard.id }.map(\.id) + [pinboard.id]
appState.panelController.selectTab(.pinboard(pinboard.id))
}

private func clearHistory() {
Expand Down Expand Up @@ -366,8 +384,9 @@ struct NavigationBarView: View {

private func deletePinboard(_ pinboard: Pinboard) {
if appState.selectedTab == .pinboard(pinboard.id) {
appState.selectedTab = .history
appState.panelController.selectTab(.history)
}
appState.orderedPinboardIDs.removeAll { $0 == pinboard.id }
modelContext.delete(pinboard)
try? modelContext.save()
}
Expand Down
7 changes: 7 additions & 0 deletions Clipbara/Views/Settings/ShortcutSettingsTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ struct ShortcutSettingsTab: View {
Spacer()
LocalKeyRecorderView()
}
Section("Tab Navigation") {
LabeledContent("History", value: "⌘1")
LabeledContent("First 8 Pinboards", value: "⌘2–⌘9")
Text("Fixed shortcuts in tab order. Only active while the history panel is open, including while searching.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.padding()
Expand Down
78 changes: 78 additions & 0 deletions Tests/PanelTabShortcutTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import AppKit
import XCTest

final class PanelTabShortcutTests: XCTestCase {
func testNumberRowMapsToVisibleTabOrder() {
let keys: [UInt16] = [18, 19, 20, 21, 23, 22, 26, 28, 25]
for (index, key) in keys.enumerated() {
XCTAssertEqual(PanelTabShortcut.index(keyCode: key, modifiers: .command), index)
}
}

func testKeypadMapsToTheSameTabs() {
let keys: [UInt16] = [83, 84, 85, 86, 87, 88, 89, 91, 92]
for (index, key) in keys.enumerated() {
XCTAssertEqual(PanelTabShortcut.index(keyCode: key, modifiers: [.command, .numericPad]), index)
}
}

func testRequiresCommand() {
for flags: NSEvent.ModifierFlags in [[], .shift, .option, .control, [.control, .shift]] {
XCTAssertNil(PanelTabShortcut.index(keyCode: 18, modifiers: flags))
}
}

func testRejectsExtraChordModifiers() {
for extra: NSEvent.ModifierFlags in [.shift, .option, .control, [.shift, .option, .control]] {
XCTAssertNil(PanelTabShortcut.index(keyCode: 19, modifiers: [.command, extra]))
}
}

func testCapsLockDoesNotDisableShortcut() {
XCTAssertEqual(PanelTabShortcut.index(keyCode: 18, modifiers: [.command, .capsLock]), 0)
}

func testZeroAndOtherKeysAreNotTabShortcuts() {
for key: UInt16 in [29, 82, 49, 36, 53, 123, 124, 0] {
XCTAssertNil(PanelTabShortcut.index(keyCode: key, modifiers: .command))
}
}

func testHistoryWorksWithoutPinboards() {
XCTAssertEqual(PanelTabShortcut.target(at: 0, pinboardIDs: []), .history)
for index in 1..<9 {
XCTAssertNil(PanelTabShortcut.target(at: index, pinboardIDs: []))
}
}

func testPinboardsFollowSuppliedDisplayOrder() {
let ids = (0..<10).map { _ in UUID() }
for index in 1..<9 {
XCTAssertEqual(PanelTabShortcut.target(at: index, pinboardIDs: ids), .pinboard(ids[index - 1]))
}
XCTAssertNil(PanelTabShortcut.target(at: 9, pinboardIDs: ids))
}

func testMissingAndOutOfRangeTabsAreNoOps() {
let id = UUID()
XCTAssertEqual(PanelTabShortcut.target(at: 1, pinboardIDs: [id]), .pinboard(id))
XCTAssertNil(PanelTabShortcut.target(at: 2, pinboardIDs: [id]))
XCTAssertNil(PanelTabShortcut.target(at: -1, pinboardIDs: [id]))
XCTAssertNil(PanelTabShortcut.target(at: Int.max, pinboardIDs: [id]))
}

func testDeletingOrReorderingTabsUsesNewPositions() {
let a = UUID(), b = UUID(), c = UUID()
XCTAssertEqual(PanelTabShortcut.target(at: 1, pinboardIDs: [a, b, c]), .pinboard(a))
XCTAssertEqual(PanelTabShortcut.target(at: 1, pinboardIDs: [b, c]), .pinboard(b))
XCTAssertEqual(PanelTabShortcut.target(at: 2, pinboardIDs: [c, b]), .pinboard(b))
}

func testHintsMatchShortcutRange() {
for index in 0..<9 {
XCTAssertEqual(PanelTabShortcut.hint(at: index), "⌘\(index + 1)")
}
XCTAssertNil(PanelTabShortcut.hint(at: -1))
XCTAssertNil(PanelTabShortcut.hint(at: 9))
}
}
44 changes: 44 additions & 0 deletions docs/testing/issue-8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Issue #8: panel-local tab shortcuts

## Contract

- Command+1 selects History. Command+2 through Command+9 select the first eight pinboards in displayed tab order.
- The number row and numeric keypad use the same mapping. Caps Lock is ignored; Shift/Option/Control combinations are not tab shortcuts.
- Missing positions do nothing. Command+0 is not assigned.
- Shortcuts work only in the open panel or its Quick Look window, including while the search field has focus.
- A create/rename/delete dialog must retain its input. Settings and other applications must not trigger tab switching.
- Switching closes an old preview and invalidates the old keyboard target before the new tab renders. The search query is preserved.
- A selected tab outside the visible strip scrolls into view. Tooltips and Settings > Shortcuts explain the fixed mapping.
- No global hotkeys, Accessibility permission, or synthetic paste events are added.

## Automated checks

```sh
bash scripts/test-tab-shortcuts.sh
```

The script generates the project, runs the 11 unhosted XCTest methods, builds Debug, and relaunches Clipbara. Unit tests do not launch the app or access its data. Relaunching the Debug app uses its normal data store. Logs and an xcresult bundle are written to the printed output directory. A nonzero step stops the script.

The unit tests exercise the actual mapping source, not a duplicate implementation. They cover number-row/keypad keys, modifiers, missing tabs, boundaries, changed tab order, and tooltip numbering. They do not prove event delivery or SwiftUI/AppKit behavior.

## macOS UI verification (not covered by unit tests)

Use a separate empty TextEdit document as the foreground application. Keep the production nonactivating NSPanel style. Use existing pinboards when possible; never clear the user's history or remove their boards for a test.

1. Open the panel, then use Command+1, Command+2, and Command+3. Check the active tab and card contents, not just the tab highlight. Switch back with Command+1.
2. Repeat a switch into an empty board, the eighth board if available, and a nonexistent numbered position. The empty board must have no stale selectable card; a missing position does nothing. Command+0 and extra-modifier chords must not switch tabs.
3. Type into search and immediately press Command+2, before the 150ms debounce completes. Wait, then check that the pinboard's contents remain the keyboard targets. Switch to History and confirm the search query is preserved.
4. Open Quick Look with Space, then press Command+2. The old preview must close. Space/Return must never act on the previous tab's clip. Repeat tab switching rapidly, including an immediate Return.
5. While a create/rename/delete confirmation is open, press Command+1/2. The dialog must remain active without navigating the panel behind it. Cancel it without modifying user data.
6. In Settings and with the panel closed, use Command+1/2. Clipbara must not open or change tabs. Reopen the panel to check.
7. If there are enough tabs to overflow the strip, switch by keyboard and confirm that the selected tab becomes visible. Check History and pinboard tooltips and the Settings > Shortcuts explanation.
8. Regression: confirm Left/Right, Space, Escape, and mouse tab clicks still work. Search and dialog text input must retain normal behavior.
9. Paste regression, with two distinct uniquely identifiable test strings: confirm independently that clicking a card changes the system clipboard to that card, closes the panel, and a subsequent manual Command+V inserts it into the empty focused TextEdit document. Also check Return immediately after a tab switch. Do not test with a card already equal to the current clipboard.

Record pass/fail or unavailable for each case. If testing creates clipboard records, remove only those exact unique test records using SwiftData and call modelContext.save(); preserve all pre-existing user data. Never count an unavailable case as passed.

## Verification status

- 2026-09-11: `scripts/test-tab-shortcuts.sh` passed end to end. 11 unit tests, 0 failures, Debug build succeeded, Debug app relaunched.
- 2026-09-11: tab switching, switching while searching, switching with Quick Look open, and switching with a pinboard dialog open were checked by hand and behaved as specified.
- Not yet covered: the paste regression case in step 9 and multi-tab scroll-into-view with an overflowing tab strip.
Loading
Loading