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 @@ -28,6 +28,7 @@ final class UpdateSurfaceIslands: SidebarUpdateSurfaces {
final class UpdateSurfaceIsland: SidebarSurfaceIsland {
let view: NSView
private(set) var contentHeight: CGFloat = 0
private(set) var contentWidth: CGFloat = 0
var onHeightChange: (() -> Void)?
var onAction: (() -> Void)?
var onDismiss: (() -> Void)?
Expand All @@ -37,7 +38,7 @@ final class UpdateSurfaceIsland: SidebarSurfaceIsland {
let provider = UpdateSurfaceProvider(kind: kind, services: services)
self.provider = provider
view = services.flatMap { PageFactory(services: $0).updateSurfacePage(kind: kind, provider: provider) } ?? NSView()
provider.onHeight = { [weak self] height in self?.setHeight(height) }
provider.onSize = { [weak self] height, width in self?.setSize(height: height, width: width) }
provider.onAction = { [weak self] in self?.onAction?() }
provider.onDismiss = { [weak self] in self?.onDismiss?() }
}
Expand All @@ -46,27 +47,30 @@ final class UpdateSurfaceIsland: SidebarSurfaceIsland {
(view as? PageWebView)?.close()
}

/// `height` is in CSS px; the page is zoomed (the interface scale), so
/// one CSS px is `pageZoom * magnification` points.
private func setHeight(_ height: CGFloat) {
/// `height` and `width` are in CSS px; the page is zoomed (the interface scale), so
/// one CSS px is `pageZoom * magnification` points. A width of 0: the page fills its host.
private func setSize(height: CGFloat, width: CGFloat) {
let web = (view as? PageWebView)?.webKitView
let zoom = (web?.pageZoom ?? 1) * (web?.magnification ?? 1)
let height = max(0, ceil(height * zoom))
guard height != contentHeight else { return }
let height = max(0, ceil(height * zoom)), width = max(0, ceil(width * zoom))
guard height != contentHeight || width != contentWidth else { return }
contentHeight = height
contentWidth = width
onHeightChange?()
}
}

/// `cmux.changelog.surface.*`: `get` (the surface's UpdateHighlights or
/// null), `size {height}` (the page's content height), `openChangelog`
/// null), `size {height, width?}` (the page's content height, and its own width when it is
/// narrower than the host), `openChangelog`
/// and `dismiss`; the stream `cmux.changelog.surface.changes` sends the
/// highlights again whenever they change (the notes arrive after staging).
@MainActor
final class UpdateSurfaceProvider: PageProvider {
let kind: SidebarUpdateSurfaceKind
private weak var services: AppServices?
var onHeight: ((CGFloat) -> Void)?
/// The page's content height and its own width (0: it fills the host), in CSS px.
var onSize: ((CGFloat, CGFloat) -> Void)?
/// The page ran an action (the popover closes).
var onAction: (() -> Void)?
/// The card's x: the sidebar sends `.dismissUpdated` (SidebarCardFeed.handle), the one
Expand All @@ -86,7 +90,12 @@ final class UpdateSurfaceProvider: PageProvider {

private func json(_ highlights: UpdateHighlights?) throws -> JSONValue {
guard let highlights else { return .null }
return try JSONValue.parse(JSONEncoder().encode(highlights))
let value = try JSONValue.parse(JSONEncoder().encode(highlights))
// The Updated card after an automatic install that relaunched cmux behind another app
// (nobody clicked Restart to Update): the card says it updated in the background.
guard kind == .updated, services?.environment.relaunchBehind != nil, case .object(var members) = value else { return value }
members["delivery"] = .string("background")
return .object(members)
}

func call(_ op: String, params: JSONValue, context: PageCallContext) async throws -> JSONValue {
Expand All @@ -101,7 +110,7 @@ final class UpdateSurfaceProvider: PageProvider {
case "cmux.changelog.surface.size":
guard let height = params["height"]?.doubleValue else { throw PageError.invalidParams("height is required") }
services.updater.log.append("update surface \(kind.rawValue): height \(Int(height))")
onHeight?(CGFloat(height))
onSize?(CGFloat(height), CGFloat(params["width"]?.doubleValue ?? 0))
return .null
case "cmux.changelog.surface.openChangelog":
_ = kind == .ready ? services.updater.openStagedChangelog() : services.updater.openWhatsNewForLastUpdate()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public protocol SidebarSurfaceIsland: AnyObject {
var view: NSView { get }
/// The content's height in points; 0 until the page has drawn.
var contentHeight: CGFloat { get }
/// The content's own width in points (the popover is as wide as its lines); 0 when the page
/// fills the width it is given.
var contentWidth: CGFloat { get }
/// The content height changed.
var onHeightChange: (() -> Void)? { get set }
/// The page ran one of its actions (the popover then closes).
Expand All @@ -24,6 +27,10 @@ public protocol SidebarSurfaceIsland: AnyObject {
func close()
}

extension SidebarSurfaceIsland {
public var contentWidth: CGFloat { 0 }
}

/// Makes the update surfaces (set by the App on each sidebar).
@MainActor
public protocol SidebarUpdateSurfaces: AnyObject {
Expand All @@ -38,19 +45,27 @@ final class SidebarPopoverIslandHost: NSView {
var onPointerExit: (() -> Void)?
private var tracking: NSTrackingArea?

/// The host's height in a hover card (the card fits its body); its width is fixed.
/// The host's size in a hover card (the card fits its body).
private var heightConstraint: NSLayoutConstraint?
private var widthConstraint: NSLayoutConstraint?
/// The widest the host gets (the menu width); a page that reports its own width is narrower.
let maxWidth: CGFloat

init(width: CGFloat) {
maxWidth = width
super.init(frame: NSRect(x: 0, y: 0, width: width, height: 1))
let height = heightAnchor.constraint(equalToConstant: 1)
NSLayoutConstraint.activate([widthAnchor.constraint(equalToConstant: width), height])
let widthConstraint = widthAnchor.constraint(equalToConstant: width)
NSLayoutConstraint.activate([widthConstraint, height])
heightConstraint = height
self.widthConstraint = widthConstraint
}

/// Sizes the host `height` tall (the page's height, capped to its window).
func fit(height: CGFloat) {
/// Sizes the host `height` tall (the page's height, capped to its window) and `width` wide
/// (the page's own width, at most ``maxWidth``; 0 is the full width).
func fit(height: CGFloat, width: CGFloat = 0) {
heightConstraint?.constant = max(1, height)
widthConstraint?.constant = width > 0 ? min(maxWidth, ceil(width)) : maxWidth
}

/// Moves the surface's page view in (it waits parked in the card while
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ extension SidebarUpdateCardView {
let host = notesHost
wireNotes(highlights)
host.adopt(highlights)
host.fit(height: notesHeight(for: highlights.contentHeight))
host.fit(height: notesHeight(for: highlights.contentHeight), width: highlights.contentWidth)
let anchor = window.convertToScreen(convert(bounds, to: nil))
let body = HoverCardBody(view: host, placement: .beside, themeAnchor: self, applyTheme: {})
cards.presentOwned(Self.notesID, body: body, anchor: anchor, parent: window, interactive: true) { [weak self] in
Expand All @@ -47,7 +47,8 @@ extension SidebarUpdateCardView {
parkHighlights()
}

/// Menu width: the popover type every cmux popover uses (320-360 pt).
/// Menu width: the popover type every cmux popover uses (320-360 pt). The most it gets: a short
/// popover (no change list) is as wide as its lines (Leo 2026-11-03: "stretched out wasted space").
static var popoverWidth: CGFloat { 340 }

/// The popup is at most this share of its window's height; the page scrolls inside it.
Expand Down Expand Up @@ -81,7 +82,7 @@ extension SidebarUpdateCardView {
notesDismissedWhileHovered = false
if notesAreShown, let highlights, highlights.contentHeight > 0 {
cancelNotesClose()
notesHost.fit(height: notesHeight(for: highlights.contentHeight))
notesHost.fit(height: notesHeight(for: highlights.contentHeight), width: highlights.contentWidth)
hoverCards()?.refitOwned(Self.notesID)
} else {
showNotes()
Expand Down Expand Up @@ -115,7 +116,7 @@ extension SidebarUpdateCardView {
notesHost.onPointerEnter = { [weak self] in self?.cancelNotesClose() }
highlights.onHeightChange = { [weak self, weak highlights] in
guard let self, let highlights, highlights.contentHeight > 0 else { return }
self.notesHost.fit(height: self.notesHeight(for: highlights.contentHeight))
self.notesHost.fit(height: self.notesHeight(for: highlights.contentHeight), width: highlights.contentWidth)
self.hoverCards()?.refitOwned(Self.notesID)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,9 @@ final class SidebarUpdateCardView: NSView {
// MARK: Geometry

/// Compact (Leo 2026-10-19): a 10 pt inset, the detail right under the
/// title, and 8 pt between the rows.
/// title. 6 pt between the rows (Leo 2026-11-03: "stretched out wasted space"; it was 8).
private static var padding: CGFloat { Metrics.space2 + Metrics.space1 / 2 }
private static var rowGap: CGFloat { Metrics.space1 + Metrics.space1 / 2 }
private static var titleFont: NSFont { Typography.bodyEmphasized }

private static var captionHeight: CGFloat { ceil(Typography.caption.boundingRectForFont.height) }
Expand All @@ -193,8 +194,8 @@ final class SidebarUpdateCardView: NSView {
static func height(for card: SidebarUpdateCard) -> CGFloat {
let title = ceil(titleFont.boundingRectForFont.height)
let detail = card.detail == nil ? 0 : Metrics.space1 / 2 + captionHeight
return ceil(padding + title + detail + Metrics.space2
+ SidebarUpdateToggle.height + Metrics.space2 + SidebarUpdateButton.height + padding)
return ceil(padding + title + detail + rowGap
+ SidebarUpdateToggle.height + rowGap + SidebarUpdateButton.height + padding)
}

override func layout() {
Expand All @@ -217,8 +218,8 @@ final class SidebarUpdateCardView: NSView {
detailLabel.frame = NSRect(x: pad, y: y, width: width, height: caption)
y += caption
}
toggle.frame = NSRect(x: pad, y: y + Metrics.space2, width: width, height: SidebarUpdateToggle.height)
button.frame = NSRect(x: pad, y: toggle.frame.maxY + Metrics.space2, width: width, height: SidebarUpdateButton.height)
toggle.frame = NSRect(x: pad, y: y + Self.rowGap, width: width, height: SidebarUpdateToggle.height)
button.frame = NSRect(x: pad, y: toggle.frame.maxY + Self.rowGap, width: width, height: SidebarUpdateButton.height)
presence.didLayout()
}

Expand Down
132 changes: 132 additions & 0 deletions webviews/src/pages/changelog/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -2633,6 +2633,138 @@
}
}
},
"updates.card.updatedBehind": {
"comment": "Updated card title after an automatic update relaunched the app while it was not in front; %@ is the new version.",
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Updated to %@ in the background"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "تم التحديث إلى %@ في الخلفية"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Ažurirano na %@ u pozadini"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Opdateret til %@ i baggrunden"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Im Hintergrund auf %@ aktualisiert"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Actualizado a %@ en segundo plano"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Mis à jour vers %@ en arrière-plan"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Aggiornato a %@ in background"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "バックグラウンドで %@ にアップデートしました"
}
},
"km": {
"stringUnit": {
"state": "translated",
"value": "បានធ្វើបច្ចុប្បន្នភាពទៅ %@ នៅផ្ទៃខាងក្រោយ"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "백그라운드에서 %@(으)로 업데이트됨"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Oppdatert til %@ i bakgrunnen"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Zaktualizowano do %@ w tle"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Atualizado para %@ em segundo plano"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Обновлено до %@ в фоне"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "อัปเดตเป็น %@ ในเบื้องหลังแล้ว"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Arka planda %@ sürümüne güncellendi"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Оновлено до %@ у фоні"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Đã cập nhật lên %@ trong nền"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "已在后台更新到 %@"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "已在背景更新至 %@"
}
}
}
},
"updates.card.dismiss": {
"comment": "Updated card close button (tooltip and VoiceOver).",
"extractionState": "manual",
Expand Down
Loading
Loading