Repository navigation
Update popover: show the top changes and a readable version line #17452
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| import Foundation | ||
| @preconcurrency import Sparkle | ||
|
|
||
| /// What the update popover says about an available update: one readable line in | ||
| /// place of the raw version string, and the first few changes from the feed. | ||
| struct UpdateSummary: Equatable { | ||
| /// The most changes listed inline before "and N more". | ||
| static let maxInlineChanges = 5 | ||
|
|
||
| /// "Nightly · Today at 12:20 PM · 23 changes" or "0.65.1 · Oct 5". | ||
| let headline: String | ||
| /// Up to ``maxInlineChanges`` change titles from the feed description. | ||
| let changes: [String] | ||
| /// Changes the feed counted but the popover does not list. | ||
| let remainingCount: Int | ||
|
|
||
| init(item: SUAppcastItem, now: Date = Date(), locale: Locale = .current, timeZone: TimeZone = .current) { | ||
| self.init( | ||
| displayVersion: item.displayVersionString, | ||
| date: item.date, | ||
| itemDescription: item.itemDescription, | ||
| now: now, | ||
| locale: locale, | ||
| timeZone: timeZone | ||
| ) | ||
| } | ||
|
|
||
| init( | ||
| displayVersion: String, | ||
| date: Date?, | ||
| itemDescription: String?, | ||
| now: Date = Date(), | ||
| locale: Locale = .current, | ||
| timeZone: TimeZone = .current | ||
| ) { | ||
| let feed = Self.parse(itemDescription ?? "") | ||
| changes = Array(feed.titles.prefix(Self.maxInlineChanges)) | ||
| remainingCount = feed.total - changes.count | ||
|
|
||
| let isNightly = displayVersion.localizedCaseInsensitiveContains("nightly") | ||
| var parts: [String] = [] | ||
| if isNightly { | ||
| parts.append(String(localized: "update.popover.channel.nightly", defaultValue: "Nightly")) | ||
| } else { | ||
| parts.append(displayVersion) | ||
| } | ||
| if let date { | ||
| parts.append(isNightly | ||
| ? Self.nightlyDate(date, now: now, locale: locale, timeZone: timeZone) | ||
| : Self.stableDate(date, locale: locale, timeZone: timeZone)) | ||
| } | ||
| if feed.total > 0 { | ||
| let total = feed.total | ||
| parts.append(String(localized: "update.popover.changeCount", defaultValue: "\(total) changes")) | ||
| } | ||
| headline = parts.joined(separator: " · ") | ||
| } | ||
|
|
||
| /// Reads the plain-text summary `scripts/ci/nightly_release_notes.py` writes | ||
| /// into the feed item's `<description>`: up to five "Title (#123)" lines, | ||
| /// then "and N more" and "Infrastructure: N" when they apply. Infrastructure | ||
| /// PRs are not counted as changes, and the empty-range sentences list none. | ||
| static func parse(_ description: String) -> (titles: [String], total: Int) { | ||
| var titles: [String] = [] | ||
| var more = 0 | ||
| for rawLine in description.components(separatedBy: .newlines) { | ||
| let line = rawLine.trimmingCharacters(in: .whitespaces) | ||
| if line.isEmpty || line.hasPrefix("Infrastructure:") || line.hasPrefix("No product PRs") || line.hasPrefix("See the release page") { | ||
| continue | ||
| } | ||
| if let count = capture(#"^and (\d+) more$"#, in: line).flatMap({ Int($0) }) { | ||
| more += count | ||
| continue | ||
| } | ||
| let title = line.replacingOccurrences(of: #"\s*\(#\d+\)$"#, with: "", options: .regularExpression) | ||
| if !title.isEmpty { | ||
| titles.append(title) | ||
|
Comment on lines
+75
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '1,115p' Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdateSummary.swift
sed -n '110,155p' scripts/ci/nightly_release_notes.pyRepository: manaflow-ai/cmux Length of output: 7361 🏁 Script executed: #!/bin/bash
set -o pipefail
printf '%s\n' '--- diff paths ---'
git diff --name-status 615a10315f13e5eeeb412a92f8d550ec1a1aba67 3a7a0622c54d5f400e77d09b49019243546e974d
printf '%s\n' '--- UpdateSummary bindings and tests ---'
rg -n -F -- 'UpdateSummary' Packages/macOS || test "$?" -eq 1
printf '%s\n' '--- producer complete appcast writer ---'
nl -ba scripts/ci/nightly_release_notes.py | sed -n '1,220p'
printf '%s\n' '--- appcast and updater feed references ---'
rg -n --glob '!**/vendor/**' --glob '!**/node_modules/**' '(appcast(-arm64|-x86_64|-universal)?\.xml|SUAppcast|itemDescription|nightly_release_notes|update_appcasts)' .github Packages scripts | head -240Repository: manaflow-ai/cmux Length of output: 33969 🏁 Script executed: #!/bin/bash
set -o pipefail
printf '%s\n' '--- popover consumer ---'
nl -ba Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdatePopoverView.swift | sed -n '60,110p'
printf '%s\n' '--- summary tests ---'
nl -ba Packages/macOS/CmuxUpdaterUI/Tests/CmuxUpdaterUITests/UpdateSummaryTests.swift | sed -n '1,125p'
printf '%s\n' '--- stable release appcast generation ---'
nl -ba .github/workflows/release.yml | sed -n '610,715p'
printf '%s\n' '--- appcast generation helper ---'
nl -ba scripts/sparkle_generate_appcast.sh | sed -n '1,180p'
printf '%s\n' '--- appcast description and release-note inputs ---'
rg -n -i --glob '*.yml' --glob '*.yaml' --glob '*.sh' --glob '*.py' --glob '*.swift' --glob '*.md' '(description|release.?notes|generate_appcast|itemDescription)' .github/workflows/release.yml .github/workflows/nightly.yml scripts Packages/macOS/CmuxUpdaterUI | head -220Repository: manaflow-ai/cmux Length of output: 41512 🌐 Web query:
💡 Result: 🌐 Web query:
💡 Result: Accept only nightly change-title lines. Sparkle 2.8.1 can pass HTML in an appcast The nightly producer always appends 🐛 Suggested fix- let title = line.replacingOccurrences(of: #"\s*\(#\d+\)$"#, with: "", options: .regularExpression)
- if !title.isEmpty {
- titles.append(title)
- }
+ guard let title = capture(#"^(.+?)\s+\(#\d+\)$"#, in: line) else { continue }
+ titles.append(title)🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| return (titles, titles.count + more) | ||
| } | ||
|
|
||
| private static func nightlyDate(_ date: Date, now: Date, locale: Locale, timeZone: TimeZone) -> String { | ||
| var calendar = Calendar(identifier: .gregorian) | ||
| calendar.timeZone = timeZone | ||
| let formatter = DateFormatter() | ||
| formatter.locale = locale | ||
| formatter.timeZone = timeZone | ||
| formatter.timeStyle = .short | ||
| if calendar.isDate(date, inSameDayAs: now) || calendar.isDate(date, inSameDayAs: now.addingTimeInterval(-86_400)) { | ||
| formatter.dateStyle = .medium | ||
| formatter.doesRelativeDateFormatting = true | ||
| } else { | ||
| formatter.setLocalizedDateFormatFromTemplate("MMMd jmm") | ||
| } | ||
| return formatter.string(from: date) | ||
| } | ||
|
|
||
| private static func stableDate(_ date: Date, locale: Locale, timeZone: TimeZone) -> String { | ||
| let formatter = DateFormatter() | ||
| formatter.locale = locale | ||
| formatter.timeZone = timeZone | ||
| formatter.setLocalizedDateFormatFromTemplate("MMMd") | ||
| return formatter.string(from: date) | ||
| } | ||
|
|
||
| private static func capture(_ pattern: String, in text: String) -> String? { | ||
| guard let regex = try? NSRegularExpression(pattern: pattern), | ||
| let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), | ||
| let range = Range(match.range(at: 1), in: text) else { return nil } | ||
| return String(text[range]) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import Foundation | ||
| import Testing | ||
| @testable import CmuxUpdaterUI | ||
|
|
||
| @Suite struct UpdateSummaryTests { | ||
| private let utc = TimeZone(identifier: "UTC")! | ||
| private let locale = Locale(identifier: "en_US") | ||
|
|
||
| private func date(_ day: Int, _ hour: Int, _ minute: Int) -> Date { | ||
| var calendar = Calendar(identifier: .gregorian) | ||
| calendar.timeZone = utc | ||
| return calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour, minute: minute))! | ||
| } | ||
|
|
||
| // The shape scripts/ci/nightly_release_notes.py writes into <description>. | ||
| private let nightlyFeed = """ | ||
| Show the top changes in the update popover (#17460) | ||
| Keep the sidebar order after a crash (#17455) | ||
| Fix a focus jump when closing a split (#17450) | ||
| Restore browser zoom per site (#17441) | ||
| Speed up workspace switching (#17432) | ||
| and 18 more | ||
| Infrastructure: 7 | ||
| """ | ||
|
|
||
| @Test func nightlyListsFiveChangesAndCountsTheRest() { | ||
| let summary = UpdateSummary( | ||
| displayVersion: "0.65.0-nightly.3737789529201", | ||
| date: date(5, 12, 20), | ||
| itemDescription: nightlyFeed, | ||
| now: date(5, 18, 0), | ||
| locale: locale, | ||
| timeZone: utc | ||
| ) | ||
| #expect(summary.changes == [ | ||
| "Show the top changes in the update popover", | ||
| "Keep the sidebar order after a crash", | ||
| "Fix a focus jump when closing a split", | ||
| "Restore browser zoom per site", | ||
| "Speed up workspace switching", | ||
| ]) | ||
| #expect(summary.remainingCount == 18) | ||
| #expect(summary.headline.hasPrefix("Nightly · Today")) | ||
| #expect(summary.headline.hasSuffix(" · 23 changes")) | ||
| #expect(!summary.headline.contains("3737789529201")) | ||
| } | ||
|
|
||
| @Test func olderNightlyShowsItsDayInsteadOfToday() { | ||
| let summary = UpdateSummary( | ||
| displayVersion: "0.65.0-nightly.3735594566602", | ||
| date: date(1, 9, 5), | ||
| itemDescription: nil, | ||
| now: date(5, 18, 0), | ||
| locale: locale, | ||
| timeZone: utc | ||
| ) | ||
| #expect(summary.headline.hasPrefix("Nightly · Oct 1")) | ||
| #expect(summary.changes.isEmpty) | ||
| } | ||
|
|
||
| @Test func stableShowsVersionAndDay() { | ||
| let summary = UpdateSummary( | ||
| displayVersion: "0.65.1", | ||
| date: date(5, 15, 0), | ||
| itemDescription: nil, | ||
| locale: locale, | ||
| timeZone: utc | ||
| ) | ||
| #expect(summary.headline == "0.65.1 · Oct 5") | ||
| #expect(summary.changes.isEmpty) | ||
| #expect(summary.remainingCount == 0) | ||
| } | ||
|
|
||
| @Test func emptyRangeListsNothing() { | ||
| let summary = UpdateSummary( | ||
| displayVersion: "0.65.0-nightly.3737789529201", | ||
| date: nil, | ||
| itemDescription: "No product PRs merged since the previous published build.", | ||
| locale: locale, | ||
| timeZone: utc | ||
| ) | ||
| #expect(summary.changes.isEmpty) | ||
| #expect(summary.headline == "Nightly") | ||
| } | ||
|
|
||
| @Test func infrastructureOnlyCountsNoChanges() { | ||
| let parsed = UpdateSummary.parse("Infrastructure: 4") | ||
| #expect(parsed.titles.isEmpty) | ||
| #expect(parsed.total == 0) | ||
| } | ||
|
|
||
| @Test func shortListHasNoRemainder() { | ||
| let summary = UpdateSummary( | ||
| displayVersion: "0.65.0-nightly.1", | ||
| date: nil, | ||
| itemDescription: "Fix a crash on launch (#17001)\nInfrastructure: 2", | ||
| locale: locale, | ||
| timeZone: utc | ||
| ) | ||
| #expect(summary.changes == ["Fix a crash on launch"]) | ||
| #expect(summary.remainingCount == 0) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Do not link nightly changes to a stable release.
If a nightly item has no
fullReleaseNotesURL, this fallback extracts0.65.0from0.65.0-nightly.3737789529201. It links “and N more” to the stablev0.65.0tag, not to the nightly changes. Use the appcast URL as the source of truth for nightly release notes. As a first migration cut, show unlinked text when that URL is absent, and cover that case with a test. This prevents other nightly versions from inheriting the same wrong destination.As per coding guidelines, a fix must “name the invariant, source of truth, or state transition that makes the whole class impossible.”
🤖 Prompt for AI Agents
Source: Coding guidelines