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
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,17 @@ enum SidebarCardFeed {
let progress: SidebarNoticeCard.Progress? = shown.showsProgress ? (shown.progress.map { .fraction($0) } ?? .indeterminate) : nil
// A found update gets the Hide button (remind me later on right-click); a result keeps the x.
let closes = shown.actions.contains(.update)
return SidebarNoticeCard(id: updateCardID, symbol: shown.symbol, title: shown.title, detail: shown.detail, lines: shown.lines, progress: progress,
// A status card: the text starts at the card's padding, with no small glyph before it, and
// its control sits on the right (Leo 2026-10-31: "WHY NOT JUST LEFT ALIGN THE TEXT PUT A
// BIGGER SYMBOL ON THE RIGHT").
var notice = SidebarNoticeCard(id: updateCardID, symbol: nil, title: shown.title, detail: shown.detail, lines: shown.lines, progress: progress,
actions: shown.actions.map { SidebarNoticeCard.Action(id: $0.rawValue, title: $0.title, prominent: $0.isProminent) },
trailing: shown.trailing.map { SidebarNoticeCard.Trailing(id: $0.rawValue, symbol: $0.symbol, label: $0.title) },
dismissLabel: shown.dismissible ? (closes ? UpdaterService.updateCloseLabel : UpdaterService.cardDismissLabel) : nil,
dismissTitle: closes && shown.dismissible ? UpdaterService.updateHideTitle : nil, dismissMenu: closes ? UpdaterService.updateCloseMenu.map(SidebarNoticeCard.Action.init) : [],
userAsked: asked)
notice.leadingSpinner = false
return notice
}

/// The staged update card (UPDATE-CARD; nil while checking or downloading).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,26 @@ public nonisolated struct SidebarNoticeCard: Hashable, Sendable {
}
}

/// A control on the card's right edge, vertically centred in the card, bigger than the x (a
/// running download's cancel; Leo 2026-10-31: "PUT A BIGGER SYMBOL ON THE RIGHT ... CENTER
/// THE FUCKING BUTTON ON THE RIGHT VERTICALLY"). Pressing it sends `noticeAction` with `id`.
public struct Trailing: Hashable, Sendable {
public var id: String
/// An SF Symbol.
public var symbol: String
/// The tooltip and VoiceOver label.
public var label: String

public init(id: String, symbol: String, label: String) {
self.id = id
self.symbol = symbol
self.label = label
}
}

public var id: String
/// An SF Symbol beside the title, nil for none.
/// An SF Symbol beside the title, nil for none (status cards show none: their text starts at
/// the card's padding).
public var symbol: String?
/// A small heading above the title ("Did you know?"), nil for none.
public var eyebrow: String?
Expand All @@ -49,6 +67,8 @@ public nonisolated struct SidebarNoticeCard: Hashable, Sendable {
public var lines: [String]
public var progress: Progress?
public var actions: [Action]
/// The control on the right, vertically centred (``Trailing``), or nil.
public var trailing: Trailing?
/// A shortcut as shown in menus ("⇧⌘P"), beside the buttons.
public var shortcut: String?
/// The x's tooltip and VoiceOver label; nil shows no x.
Expand All @@ -71,12 +91,16 @@ public nonisolated struct SidebarNoticeCard: Hashable, Sendable {
/// progress and result): it takes the bottom-left slot from the Now
/// Playing and "cmux Updated!" cards, so the answer is never behind them.
public var userAsked: Bool
/// Unmeasured work shows a small spinner before the title. Status cards turn it off: their
/// text starts at the card's padding and the bar's sweep shows the work (Leo 2026-10-31: "WHY
/// NOT JUST LEFT ALIGN THE TEXT").
public var leadingSpinner = true
/// Something waits on the person's answer (``SidebarAsks``): the card
/// takes the slot from every other card, the staged update card too.
public var awaitsAnswer = false

public init(id: String, symbol: String? = nil, eyebrow: String? = nil, title: String, detail: String? = nil,
lines: [String] = [], progress: Progress? = nil, actions: [Action] = [], shortcut: String? = nil, dismissLabel: String? = nil,
lines: [String] = [], progress: Progress? = nil, actions: [Action] = [], trailing: Trailing? = nil, shortcut: String? = nil, dismissLabel: String? = nil,
dismissTitle: String? = nil, dismissMenu: [Action] = [], shownAction: String? = nil, hiddenAction: String? = nil, userAsked: Bool = false) {
self.id = id
self.symbol = symbol
Expand All @@ -86,6 +110,7 @@ public nonisolated struct SidebarNoticeCard: Hashable, Sendable {
self.lines = lines
self.progress = progress
self.actions = actions
self.trailing = trailing
self.shortcut = shortcut
self.dismissLabel = dismissLabel
self.dismissTitle = dismissTitle
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ final class SidebarIconButton: ClickableButton {
/// the pack has no name for, such as the group editor's emoji button.
private var symbol: String?

/// Replaces the SF Symbol a ``init(symbol:pointSize:label:)`` button draws.
func setSymbol(_ name: String) {
guard name != symbol else { return }
symbol = name
renderedSize = 0
renderIcon()
}

init(icon: IconName, pointSize: @escaping () -> CGFloat = { Metrics.smallIconSize }, label: String) {
self.icon = icon
self.label = label
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ final class SidebarNoticeCardView: NSView {
/// The action drawn as a switch (``SidebarNoticeCard/Action/isSwitch``), right of the buttons; nil until one shows.
private(set) var actionSwitch: SidebarCardSwitch?
let closeButton = SidebarIconButton(icon: .actionClose, pointSize: { 9 }, label: "")
/// The control on the right, vertically centred in the card (``SidebarNoticeCard/trailing``):
/// a running download's cancel.
let trailingButton = SidebarIconButton(symbol: "xmark.circle.fill", pointSize: { SidebarNoticeCardView.trailingGlyph }, label: "")
/// The text button that replaces the x when the notice names one (``SidebarNoticeCard/dismissTitle``).
let dismissButton = SidebarCardTextButton()
/// The card's surface: the composer's, opaque under Reduce Transparency.
Expand Down Expand Up @@ -76,7 +79,16 @@ final class SidebarNoticeCardView: NSView {
closeButton.takesKeyFocus = true
closeButton.onPress = { [weak self] in self?.closePressed() }
dismissButton.onPress = { [weak self] in if let id = self?.notice?.id { self?.onDismiss?(id) } }
[eyebrowLabel, titleLabel, detailLabel, shortcutLabel, icon, spinner, closeButton, dismissButton].forEach(content.addSubview)
trailingButton.takesKeyFocus = true
trailingButton.takesFirstClick = true
trailingButton.showsLabelToolTip = true
trailingButton.onPress = { [weak self] in
guard let id = self?.notice?.id, let action = self?.notice?.trailing?.id else { return }
self?.onAction?(id, action)
}
trailingButton.isHidden = true
[eyebrowLabel, titleLabel, detailLabel, shortcutLabel, icon, spinner, closeButton, dismissButton, trailingButton].forEach(content.addSubview)
trailingButton.setAccessibilityIdentifier("cmux.sidebar.notice.trailing")
closeButton.setAccessibilityIdentifier("cmux.sidebar.notice.close")
dismissButton.setAccessibilityIdentifier("cmux.sidebar.notice.dismiss")
setAccessibilityElement(false)
Expand Down Expand Up @@ -128,14 +140,20 @@ final class SidebarNoticeCardView: NSView {
}
shortcutLabel.stringValue = notice.shortcut ?? ""
shortcutLabel.isHidden = notice.shortcut == nil || notice.actions.isEmpty
let spins = notice.progress == .indeterminate
let spins = notice.progress == .indeterminate && notice.leadingSpinner
icon.isHidden = notice.symbol == nil || spins
spinner.spin(spins)
closeButton.isHidden = notice.dismissLabel == nil || notice.dismissTitle != nil
closeButton.label = notice.dismissLabel ?? ""
dismissButton.isHidden = notice.dismissTitle == nil
dismissButton.configure(title: notice.dismissTitle ?? "", help: notice.dismissLabel)
if previous?.actions != notice.actions { rebuildButtons(notice.actions) }
trailingButton.isHidden = notice.trailing == nil
if let trailing = notice.trailing {
trailingButton.setSymbol(trailing.symbol)
trailingButton.label = trailing.label
trailingButton.setAccessibilityIdentifier("cmux.sidebar.notice.action.\(trailing.id)")
}
let help = [notice.title, notice.detail].compactMap { $0 }.joined(separator: ". ")
for (button, action) in zip(actionButtons, notice.actions.filter { !$0.isSwitch }) {
button.configure(title: action.title, enabled: true, help: help)
Expand Down Expand Up @@ -240,7 +258,7 @@ final class SidebarNoticeCardView: NSView {
static func changesMessage(from old: SidebarNoticeCard, to new: SidebarNoticeCard) -> Bool {
let ticking = old.progress != nil && new.progress != nil && old.title == new.title
return old.id != new.id || old.title != new.title || (old.detail != new.detail && !ticking) || old.symbol != new.symbol
|| old.eyebrow != new.eyebrow || old.lines != new.lines || old.actions != new.actions
|| old.eyebrow != new.eyebrow || old.lines != new.lines || old.actions != new.actions || old.trailing != new.trailing
|| (old.progress == nil) != (new.progress == nil)
}

Expand All @@ -254,6 +272,9 @@ final class SidebarNoticeCardView: NSView {
/// List lines a notice shows.
static let maxLines = 4
private static let closeSide: CGFloat = 16
/// The right-hand control's glyph and hit square: well above the x's, on the shared spacing tokens.
static var trailingGlyph: CGFloat { Metrics.space6 + Metrics.space1 }
static var trailingSide: CGFloat { Metrics.space6 + Metrics.space4 + Metrics.space2 }

/// The card's height for `notice`: padding, the heading, the title, two
/// detail lines, the progress bar and the button row when present.
Expand Down Expand Up @@ -287,7 +308,13 @@ final class SidebarNoticeCardView: NSView {
dismissButton.frame = NSRect(x: b.width - pad / 2 - dismissWidth, y: titleY + (line.body - dismissHeight) / 2,
width: dismissWidth, height: dismissHeight)
let width = max(0, b.width - 2 * pad)
let closeRoom = !dismissButton.isHidden ? dismissWidth + Metrics.space1 : closeButton.isHidden ? 0 : close
// The right-hand control, vertically centred in the whole card; the text and the bar stop
// a grid step short of it.
let trailingSide = Self.trailingSide
let trailingRoom = trailingButton.isHidden ? 0 : trailingSide + Metrics.space4
trailingButton.frame = NSRect(x: b.width - pad - trailingSide, y: ((b.height - trailingSide) / 2).rounded(),
width: trailingSide, height: trailingSide)
let closeRoom = !dismissButton.isHidden ? dismissWidth + Metrics.space1 : closeButton.isHidden ? trailingRoom : close
var y = pad
if !eyebrowLabel.isHidden {
eyebrowLabel.frame = NSRect(x: pad, y: y, width: max(0, width - closeRoom), height: line.caption)
Expand All @@ -307,7 +334,7 @@ final class SidebarNoticeCardView: NSView {
closeButton.frame = NSRect(x: b.width - pad / 2 - close, y: ((capMid - close / 2) * scale).rounded() / scale,
width: close, height: close)
y += line.body
let textWidth = max(0, b.width - pad - textX)
let textWidth = max(0, b.width - pad - textX - trailingRoom)
if !detailLabel.isHidden {
detailLabel.preferredMaxLayoutWidth = textWidth
detailLabel.frame = NSRect(x: textX, y: y + Metrics.space1, width: textWidth, height: 2 * line.caption)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ nonisolated public struct UpdateCardPresentation: Equatable, Sendable {
public var showsProgress = false
public var progress: Double?
public var actions: [UpdateCardAction] = []
/// The control on the card's right, vertically centred (a running download's cancel), or nil.
public var trailing: UpdateCardAction?
/// Changelog lines under the detail (a found update; cx-lntk).
public var lines: [String] = []
public var dismissible = true
Expand All @@ -36,7 +38,7 @@ extension UpdateCard {
case .downloading:
return UpdateCardPresentation(symbol: "arrow.down.circle", title: UpdaterStrings.downloading,
detail: transfer.bytesText ?? UpdaterStrings.keepsRunning, showsProgress: true,
progress: transfer.fraction, dismissible: false)
progress: transfer.fraction, trailing: .cancelDownload, dismissible: false)
case .preparing:
return UpdateCardPresentation(symbol: "shippingbox", title: UpdaterStrings.preparing,
detail: UpdaterStrings.preparingDetail, showsProgress: true,
Expand Down
15 changes: 15 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,31 @@ nonisolated public enum UpdateCardAction: String, Sendable, CaseIterable {
case retry
/// Show a failure's details (the update sheet).
case details
/// Stop the running download: Sparkle deletes the partial file and the card goes back to the
/// found update, whose Update downloads it again (Leo 2026-10-31: "WHY CAN'T I CANCEL THE DOWNLOAD").
case cancelDownload = "cancel_download"

public var title: String {
switch self {
case .update: UpdaterStrings.update
case .releaseNotes: UpdaterStrings.releaseNotes
case .retry: UpdaterStrings.retry
case .details: UpdaterStrings.details
case .cancelDownload: UpdaterStrings.cancel
}
}

/// The call to action (filled); the others are quiet.
public var isProminent: Bool { self == .update }

/// The SF Symbol of an action drawn as the card's right-hand control (``UpdateCardPresentation/trailing``).
public var symbol: String {
switch self {
case .cancelDownload: "xmark.circle.fill"
case .update: "arrow.down.circle"
case .releaseNotes: "doc.text"
case .retry: "arrow.clockwise"
case .details: "info.circle"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import Foundation

/// The download's cancel on the notice card (Leo 2026-10-31: "AND WHY CAN'T I CANCEL THE DOWNLOAD
/// OPTIONALLY"). Sparkle's cancellation stops the transfer and deletes the partial file; the card
/// then goes back to the found update, whose Update checks again and downloads what it finds.
struct CancelledDownload: Equatable {
/// The found update's display version, when Sparkle named it.
var version: String?
}

extension UpdaterService {
/// The cancel on the downloading card: stops the download and shows the found update again.
/// Nothing happens unless a download runs (a late click after it finished does nothing).
public func cancelDownloadClicked() {
syncFlowPhase()
guard case .downloading(let transfer) = flow.phase, transfer.stage == .downloading else {
log.append("download cancel ignored: no download runs (\(flow.phase))")
return
}
let version = controller?.model.detectedUpdateVersion
guard cancelDownloadNow() else {
log.append("download cancel ignored: Sparkle holds no download to cancel")
return
}
log.append("download cancelled by the person; the partial file is removed")
downloadAfterCheck = false
cancelledDownload = CancelledDownload(version: version)
syncFlowPhase()
}

/// The card a cancelled download leaves: its found update, until Sparkle moves again.
var cancelledDownloadCard: UpdateCard? {
guard let cancelledDownload, flow.phase == .hidden else { return nil }
if updateSnooze.hides(version: cancelledDownload.version, at: now()) { return nil }
return .available(version: cancelledDownload.version)
}

/// Update on a cancelled download's card: Sparkle let go of the found update, so check
/// again; the update the check finds downloads (``followCancelledDownload(_:)``). Returns
/// whether the click was this card's.
func downloadAgainIfCancelled() -> Bool {
guard cancelledDownload != nil, flow.phase == .hidden else { return false }
cancelledDownload = nil
downloadAfterCheck = true
log.append("update after a cancelled download: checking again")
checkForUpdates()
return true
}

/// Follows Sparkle after a cancel: any move ends the cancelled card; the check that Update
/// started downloads the update it finds, and ends with its result otherwise.
func followCancelledDownload(_ phase: UpdateIndicatorPhase) {
defer { lastCancelFollowed = phase }
if phase != .hidden { cancelledDownload = nil }
guard downloadAfterCheck else { return }
switch phase {
case .checking:
break
case .hidden:
// The check ended with nothing (cancelled, or idle before a result): a later scheduled
// check's update never downloads on this old click. A hidden phase before the check
// starts (the cancel's own idle) keeps it.
if lastCancelFollowed == .checking { downloadAfterCheck = false }
case .available:
downloadAfterCheck = false
send(.downloadRequested)
case .downloading, .ready, .installing, .note:
downloadAfterCheck = false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ extension UpdaterService {
// A found update nobody asked about (downloads wait for a click) is the sidebar footer's quiet
// Update Available button (``availableButton``), not this card; a check they asked for shows here.
if let newestCard { return newestCard }
if let cancelledDownloadCard { return cancelledDownloadCard }
guard !stagedPulledHeld, let card = flow.card(downloadsAutomatically: true) else { return nil }
if card == .installing, installCardDismissed { return nil }
if case .available(let version) = card, updateSnooze.hides(version: version, at: now()) { return nil }
Expand Down Expand Up @@ -46,6 +47,7 @@ extension UpdaterService {
/// repeated or late press (the download already running, or staged) does nothing.
public func downloadClicked() {
syncFlowPhase()
if downloadAgainIfCancelled() { return }
guard case .available = flow.phase else {
log.append("update-found answer ignored: nothing waits on that step (\(flow.phase))")
return
Expand Down Expand Up @@ -189,6 +191,7 @@ extension UpdaterService {
let clickedInstall = flow.installRequested
let previous = flow.phase
send(.sparkle(phase))
followCancelledDownload(phase)
carryInstallClick(phase)
followNewestFetch(phase, previous: previous)
followInstall(phase)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ extension UpdaterService {
case .update: downloadClicked()
case .retry: checkForUpdates()
case .details: presentUpdateUI?()
case .cancelDownload: cancelDownloadClicked()
case .releaseNotes: break
}
}
Expand Down
Loading
Loading