From 8d534ecb8b3ef708af8ea8c84534f780d5f79156 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Wed, 1 Jul 2026 13:35:01 +0100 Subject: [PATCH] fix(tickets): smooth cursor nav --- panel/OutcomesView.swift | 22 +++++++++++++++++----- panel/PanelNav.swift | 16 ++++++++++++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/panel/OutcomesView.swift b/panel/OutcomesView.swift index 264ab73..71649c0 100644 --- a/panel/OutcomesView.swift +++ b/panel/OutcomesView.swift @@ -85,7 +85,6 @@ struct OutcomesView: View { VStack(alignment: .leading, spacing: 8) { ForEach(groups) { group in groupRow(group, selectedRowID: selectedRowID) - .id(Self.headerID(group)) } } .padding(.horizontal, 14) @@ -97,9 +96,12 @@ struct OutcomesView: View { .scrollIndicators(.visible) .onChange(of: nav.outcomeSelectedIndex) { _ in guard let selectedRowID else { return } - withAnimation(.easeOut(duration: 0.15)) { - proxy.scrollTo(selectedRowID, anchor: .center) - } + // Snap, don't animate. With key-repeat on a long list the + // 0.15s animations piled up, the scroll lagged the + // selection, then settled with an upward snap — the + // "bounce". Instant scroll keeps the selection pinned and + // the viewport tracking it smoothly. + proxy.scrollTo(selectedRowID, anchor: .center) } } } @@ -161,6 +163,16 @@ struct OutcomesView: View { .fixedSize() } } + .padding(.vertical, 2) + // Selection highlight + scroll anchor live on the header ROW, not the + // whole group container (which spans all its branches). A + // container-wide accent lit up the entire block when a header was + // selected — reading as an upward "jump" when crossing from a group's + // last branch to the next group's header. A per-row highlight keeps + // the selection a small, consistent marker that steps straight down. + .background(RoundedRectangle(cornerRadius: 4) + .fill(selected ? Color.accentColor.opacity(0.14) : Color.clear)) + .id(Self.headerID(group)) Text(detailLine(group)) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) @@ -184,7 +196,7 @@ struct OutcomesView: View { .padding(.horizontal, 8) .padding(.vertical, 6) .background(RoundedRectangle(cornerRadius: 6) - .fill(selected ? Color.accentColor.opacity(0.14) : Color.primary.opacity(0.04))) + .fill(Color.primary.opacity(0.04))) .contentShape(Rectangle()) .onTapGesture { if linkable { openTicket(group) } } .help(linkable ? "Open \(group.label) in your tracker" : "") diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 422d7ea..da50314 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -264,7 +264,13 @@ final class PanelNav: ObservableObject { // the Tickets tab (OutcomesView) and its tab-strip count re-read the // in-memory HandoffLedger and reflect the new session live. The ledger // isn't itself observable; this is the single change-signal that drives it. - @Published var handoffsRevision: Int = 0 + @Published var handoffsRevision: Int = 0 { didSet { cachedOutcomeGroups = nil } } + // Memoized grouping of the ledger (the expensive part: regex ticket + // attribution + branch breakdown + sorts). Invalidated only when the ledger + // changes (handoffsRevision) — NOT on selection moves, so holding ↑/↓ on the + // Tickets tab doesn't re-derive every keystroke. The hideShipped filter is + // applied per call on top, since it reads live outcome/PR state. + private var cachedOutcomeGroups: [TicketGroup]? // Derived "did it ship?" status per repo+branch, computed off-main by // PanelController's outcome pass and read by the Tickets tab. Keyed by // `outcomeKey(repoRoot, branch)`. Empty until the first pass completes. @@ -307,7 +313,13 @@ final class PanelNav: ObservableObject { // The groups the Tickets tab renders, after the hide-shipped filter. Single // source of truth so the view and the keyboard indexing never disagree. func visibleOutcomeGroups() -> [TicketGroup] { - let groups = OutcomesView.groups(from: HandoffLedger.shared.all()) + let groups: [TicketGroup] + if let cached = cachedOutcomeGroups { + groups = cached + } else { + groups = OutcomesView.groups(from: HandoffLedger.shared.all()) + cachedOutcomeGroups = groups + } guard hideShippedTickets else { return groups } return groups.filter { !isShipped($0) } }