Skip to content
Merged
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
18 changes: 18 additions & 0 deletions Tests/StackNudgePanelCoreTests/EventStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 8 additions & 0 deletions Tests/StackNudgePanelCoreTests/SettingsRowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions panel/EventStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
1 change: 1 addition & 0 deletions panel/OutcomesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: ["⌫"])
}
Expand Down
52 changes: 52 additions & 0 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 }),
Expand Down
14 changes: 14 additions & 0 deletions panel/PanelNav.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions panel/Phrases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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"])
}
}
Expand Down
2 changes: 2 additions & 0 deletions panel/SessionUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: ["→"])
Expand Down
1 change: 1 addition & 0 deletions panel/Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
1 change: 1 addition & 0 deletions panel/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down