From d1c7d1381e32854576a13d35d5754c58d799594c Mon Sep 17 00:00:00 2001 From: StuBehan Date: Tue, 30 Jun 2026 10:00:59 +0100 Subject: [PATCH] feat(panel): jump to top/bottom with cmd+up/down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘↑/↓ now jumps the current page to its first/last item, from any scroll position. Hooks into the existing cmdOnly block in panelHandlesKey and dispatches per mode via jumpToEdge: - Events / Sessions / Tickets / Settings / Phrases move the selection to the first/last row — the viewport follows, since each page already scrolls to its selection. - Usage: the client list jumps first/last client; the detail pane (no selection) scrolls the clip view to the top/bottom edge. - Modal/single-purpose screens return false so the keystroke passes through. Adds selectFirst/selectLast helpers to EventStore, Phrases, and PanelNav (settings rows, outcomes index, usage client) and a scrollDetailToEdge analog to scrollDetailBy. Each page footer gains a "Top/Bottom ⌘↑↓" hint. Tests: EventStore first/last (incl. empty) and PanelNav settings-row jumps. Co-Authored-By: Claude Opus 4.8 --- .../EventStoreTests.swift | 18 +++++++ .../SettingsRowTests.swift | 8 +++ panel/EventStore.swift | 4 ++ panel/OutcomesView.swift | 1 + panel/Panel.swift | 52 +++++++++++++++++++ panel/PanelNav.swift | 14 +++++ panel/Phrases.swift | 5 ++ panel/SessionUsage.swift | 2 + panel/Sessions.swift | 1 + panel/Settings.swift | 1 + 10 files changed, 106 insertions(+) diff --git a/Tests/StackNudgePanelCoreTests/EventStoreTests.swift b/Tests/StackNudgePanelCoreTests/EventStoreTests.swift index bdb2b28..418edd4 100644 --- a/Tests/StackNudgePanelCoreTests/EventStoreTests.swift +++ b/Tests/StackNudgePanelCoreTests/EventStoreTests.swift @@ -26,6 +26,24 @@ final class EventStoreTests: XCTestCase { XCTAssertEqual(store.selectedID, event.id) } + func test_selectFirstAndLast_jumpToEnds() { + let store = EventStore() + let oldest = makeEvent(message: "oldest") + let newest = makeEvent(message: "newest") + store.append(oldest) + store.append(newest) // newest-first: events == [newest, oldest] + store.selectLast() + XCTAssertEqual(store.selectedID, oldest.id) // bottom row + store.selectFirst() + XCTAssertEqual(store.selectedID, newest.id) // top row + } + + func test_selectFirst_onEmptyStore_clearsSelection() { + let store = EventStore() + store.selectFirst() + XCTAssertNil(store.selectedID) + } + func test_append_truncatesPastMaxEvents() { // maxEvents is 5; we send 7. let store = EventStore() diff --git a/Tests/StackNudgePanelCoreTests/SettingsRowTests.swift b/Tests/StackNudgePanelCoreTests/SettingsRowTests.swift index eb46464..5125b47 100644 --- a/Tests/StackNudgePanelCoreTests/SettingsRowTests.swift +++ b/Tests/StackNudgePanelCoreTests/SettingsRowTests.swift @@ -17,6 +17,14 @@ final class SettingsRowTests: XCTestCase { XCTAssertEqual(nav.rowCount, rows.count) } + func test_selectFirstRow_andLastRow_jumpToEnds() { + let nav = PanelNav() + nav.selectLastRow() + XCTAssertEqual(nav.selectedSettingIndex, nav.rowCount - 1) + nav.selectFirstRow() + XCTAssertEqual(nav.selectedSettingIndex, 0) + } + func test_keepOpenWhenEmpty_followsPinPanel() { let nav = PanelNav() let rows = nav.settingsRows diff --git a/panel/EventStore.swift b/panel/EventStore.swift index 54cb719..71fbe1d 100644 --- a/panel/EventStore.swift +++ b/panel/EventStore.swift @@ -212,6 +212,10 @@ final class EventStore: ObservableObject { selectedID = events[max(idx - 1, 0)].id } + // ⌘↑/↓ — jump to the first / last event. + func selectFirst() { selectedID = events.first?.id } + func selectLast() { selectedID = events.last?.id } + var selectedEvent: NudgeEvent? { events.first { $0.id == selectedID } } diff --git a/panel/OutcomesView.swift b/panel/OutcomesView.swift index 264ab73..7f369a6 100644 --- a/panel/OutcomesView.swift +++ b/panel/OutcomesView.swift @@ -108,6 +108,7 @@ struct OutcomesView: View { FooterHint(label: footerStatus(groups), keys: []) if !groups.isEmpty { FooterHint(label: "Select", keys: ["↑↓"]) + FooterHint(label: "Top/Bottom", keys: ["⌘↑↓"]) FooterHint(label: "Open", keys: ["↵"]) FooterHint(label: "Remove", keys: ["⌫"]) } diff --git a/panel/Panel.swift b/panel/Panel.swift index 55fc535..477daa1 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -303,6 +303,7 @@ struct PanelContentView: View { FooterDivider() } FooterHint(label: "Select", keys: ["↑", "↓"]) + FooterHint(label: "Top/Bottom", keys: ["⌘↑↓"]) // Snooze is always rendered so the footer never reflows when // selection moves between event types — dimmed when the row // isn't snoozable. The S key is wired to fire only for @@ -1313,6 +1314,19 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, scrollView.reflectScrolledClipView(clip) } + // ⌘↑/↓ in a pure-scroll detail pane (no selection to move): jump the clip + // view to the very top or bottom. + private func scrollDetailToEdge(top: Bool) { + guard let scrollView = findScrollView(in: panel.contentView), + let doc = scrollView.documentView else { return } + let clip = scrollView.contentView + let maxY = max(0, doc.frame.height - clip.bounds.height) + var origin = clip.bounds.origin + origin.y = top ? 0 : maxY + clip.scroll(to: origin) + scrollView.reflectScrolledClipView(clip) + } + private func findScrollView(in view: NSView?) -> NSScrollView? { guard let view else { return nil } if let sv = view as? NSScrollView { return sv } @@ -2288,6 +2302,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } return true } + case KeyCode.upArrow, KeyCode.downArrow: + // Cmd+↑/↓ jump the current page to its first / last item (the + // scroll follows selection). Returns false for screens with + // nothing to jump, so the key passes through untouched. + return jumpToEdge(top: event.keyCode == KeyCode.upArrow) default: break } @@ -2681,6 +2700,39 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, sessions.selectedPID = pids[max(idx - 1, 0)] } + // ⌘↑/↓ — jump to the first / last session. + private func selectFirstSession() { sessions.selectedPID = sessions.sessions.first?.pid } + private func selectLastSession() { sessions.selectedPID = sessions.sessions.last?.pid } + + // Dispatch ⌘↑/↓ to the current page's "jump to first/last". Selection-driven + // pages move their selection (the viewport follows); the Usage detail pane + // has no selection, so it scrolls to the edge. Returns false on screens with + // nothing to jump so the keystroke passes through. + private func jumpToEdge(top: Bool) -> Bool { + switch nav.mode { + case .events: + top ? store.selectFirst() : store.selectLast() + case .sessions: + guard sessions.renamingPID == nil else { return false } + top ? selectFirstSession() : selectLastSession() + case .usage: + if nav.usageDetailFocused { + scrollDetailToEdge(top: top) + } else { + top ? nav.selectFirstUsageClient() : nav.selectLastUsageClient() + } + case .outcomes: + nav.jumpOutcomeSelection(toLast: !top) + case .settings: + top ? nav.selectFirstRow() : nav.selectLastRow() + case .phrases: + top ? phrases.selectFirst() : phrases.selectLast() + default: + return false // modal / single-purpose screens have nothing to jump + } + return true + } + private func focusSelectedSession() { guard let pid = sessions.selectedPID, let session = sessions.sessions.first(where: { $0.pid == pid }), diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 422d7ea..ed4acdb 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -335,6 +335,12 @@ final class PanelNav: ObservableObject { guard count > 0 else { return } outcomeSelectedIndex = min(max(0, outcomeSelectedIndex + delta), count - 1) } + // ⌘↑/↓ — jump to the first / last Tickets row. + func jumpOutcomeSelection(toLast: Bool) { + let count = outcomeRowCount() + guard count > 0 else { return } + outcomeSelectedIndex = toLast ? count - 1 : 0 + } // Open the selected row's link: a ticket header → its tracker URL // (STACKNUDGE_TICKET_URL); a branch sub-row → its PR. Repo headers don't @@ -450,6 +456,10 @@ final class PanelNav: ObservableObject { guard availableUsageClients.count > 1 else { return } usageClientIndex = max(clampedUsageClientIndex - 1, 0) } + + // ⌘↑/↓ — jump to the first / last connected client. + func selectFirstUsageClient() { guard !availableUsageClients.isEmpty else { return }; usageClientIndex = 0 } + func selectLastUsageClient() { let count = availableUsageClients.count; guard count > 0 else { return }; usageClientIndex = count - 1 } // Transient feedback for the "Check for updates…" action row. // Set by PanelController around UpdateChecker.check(); cleared // back to .idle a few seconds after a terminal result so the @@ -856,6 +866,10 @@ final class PanelNav: ObservableObject { selectedSettingIndex = (selectedSettingIndex - 1 + rowCount) % rowCount } + // ⌘↑/↓ — jump to the first / last settings row. + func selectFirstRow() { guard rowCount > 0 else { return }; selectedSettingIndex = 0 } + func selectLastRow() { guard rowCount > 0 else { return }; selectedSettingIndex = rowCount - 1 } + // MARK: - Cycle / activate // Enter: toggles flip, cycle rows step forward, actions fire, hotkey diff --git a/panel/Phrases.swift b/panel/Phrases.swift index 6209afd..04555d6 100644 --- a/panel/Phrases.swift +++ b/panel/Phrases.swift @@ -243,6 +243,10 @@ final class PhrasesViewModel: ObservableObject { } } + // ⌘↑/↓ — jump to the first / last navigable row. + func selectFirst() { selectedRow = navigableRows.first } + func selectLast() { selectedRow = navigableRows.last } + // Space: toggle a default on/off if a default is selected. No-op on custom. func toggleSelected() { guard let row = selectedRow, row.isDefault else { return } @@ -496,6 +500,7 @@ struct PhrasesView: View { FooterHint(label: "Add", keys: ["⏎"], primary: true) FooterHint(label: "Toggle", keys: ["␣"]) FooterHint(label: "Remove", keys: ["⌫"]) + FooterHint(label: "Top/Bottom", keys: ["⌘↑↓"]) FooterHint(label: "Back", keys: ["Esc"]) } } diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index 0e0ac57..5fe3bea 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -331,10 +331,12 @@ struct UsageView: View { FooterHint(label: footerStatusLabel, keys: []) if nav.usageDetailFocused { FooterHint(label: "Scroll", keys: ["↑↓"]) + FooterHint(label: "Top/Bottom", keys: ["⌘↑↓"]) FooterHint(label: "Back", keys: ["←"]) } else { if nav.availableUsageClients.count > 1 { FooterHint(label: "Switch", keys: ["↑↓"]) + FooterHint(label: "Top/Bottom", keys: ["⌘↑↓"]) } if !nav.availableUsageClients.isEmpty { FooterHint(label: "Enter", keys: ["→"]) diff --git a/panel/Sessions.swift b/panel/Sessions.swift index 9d603bc..2a0f7e1 100644 --- a/panel/Sessions.swift +++ b/panel/Sessions.swift @@ -81,6 +81,7 @@ struct SessionsView: View { FooterHint(label: "Cancel", keys: ["Esc"]) } else { FooterHint(label: "Select", keys: ["↑", "↓"]) + FooterHint(label: "Top/Bottom", keys: ["⌘↑↓"]) FooterHint(label: "Focus", keys: ["⏎"]) FooterHint(label: "Rename", keys: ["N"]) FooterHint(label: "Mute", keys: ["M"]) diff --git a/panel/Settings.swift b/panel/Settings.swift index 85c4111..19b3a87 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -128,6 +128,7 @@ struct SettingsView: View { FooterHint(label: "Cancel", keys: ["Esc"]) } else { FooterHint(label: "Move", keys: ["↑", "↓"]) + FooterHint(label: "Top/Bottom", keys: ["⌘↑↓"]) FooterHint(label: "Cycle", keys: ["←", "→"]) FooterHint(label: "Act", keys: ["⏎"]) FooterHint(label: "Back", keys: ["Esc"])