summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--macos/Sources/Features/Update/UpdatePopoverView.swift22
-rw-r--r--macos/Sources/Features/Update/UpdateViewModel.swift70
-rw-r--r--macos/Tests/Update/ReleaseNotesTests.swift130
3 files changed, 222 insertions, 0 deletions
diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift
index ae1dc9c28..a73116ca0 100644
--- a/macos/Sources/Features/Update/UpdatePopoverView.swift
+++ b/macos/Sources/Features/Update/UpdatePopoverView.swift
@@ -191,6 +191,28 @@ fileprivate struct UpdateAvailableView: View {
}
}
.padding(16)
+
+ if let notes = update.releaseNotes {
+ Divider()
+
+ Link(destination: notes.url) {
+ HStack {
+ Image(systemName: "doc.text")
+ .font(.system(size: 11))
+ Text(notes.label)
+ .font(.system(size: 11, weight: .medium))
+ Spacer()
+ Image(systemName: "arrow.up.right")
+ .font(.system(size: 10))
+ }
+ .foregroundColor(.primary)
+ .padding(12)
+ .frame(maxWidth: .infinity)
+ .background(Color(nsColor: .controlBackgroundColor))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
}
}
}
diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift
index 674888bb5..7b6119771 100644
--- a/macos/Sources/Features/Update/UpdateViewModel.swift
+++ b/macos/Sources/Features/Update/UpdateViewModel.swift
@@ -173,6 +173,76 @@ enum UpdateState: Equatable {
struct UpdateAvailable {
let appcastItem: SUAppcastItem
let reply: @Sendable (SPUUserUpdateChoice) -> Void
+
+ var releaseNotes: ReleaseNotes? {
+ let currentCommit = Bundle.main.infoDictionary?["GhosttyCommit"] as? String
+ return ReleaseNotes(displayVersionString: appcastItem.displayVersionString, currentCommit: currentCommit)
+ }
+ }
+
+ enum ReleaseNotes {
+ case commit(URL)
+ case compareTip(URL)
+ case tagged(URL)
+
+ init?(displayVersionString: String, currentCommit: String?) {
+ let version = displayVersionString
+
+ // Check for semantic version (x.y.z)
+ if let semver = Self.extractSemanticVersion(from: version) {
+ let slug = semver.replacingOccurrences(of: ".", with: "-")
+ if let url = URL(string: "https://ghostty.org/docs/install/release-notes/\(slug)") {
+ self = .tagged(url)
+ return
+ }
+ }
+
+ // Fall back to git hash detection
+ guard let newHash = Self.extractGitHash(from: version) else {
+ return nil
+ }
+
+ if let currentHash = currentCommit, !currentHash.isEmpty,
+ let url = URL(string: "https://github.com/ghostty-org/ghostty/compare/\(currentHash)...\(newHash)") {
+ self = .compareTip(url)
+ } else if let url = URL(string: "https://github.com/ghostty-org/ghostty/commit/\(newHash)") {
+ self = .commit(url)
+ } else {
+ return nil
+ }
+ }
+
+ private static func extractSemanticVersion(from version: String) -> String? {
+ let pattern = #"^\d+\.\d+\.\d+$"#
+ if version.range(of: pattern, options: .regularExpression) != nil {
+ return version
+ }
+ return nil
+ }
+
+ private static func extractGitHash(from version: String) -> String? {
+ let pattern = #"[0-9a-f]{7,40}"#
+ if let range = version.range(of: pattern, options: .regularExpression) {
+ return String(version[range])
+ }
+ return nil
+ }
+
+ var url: URL {
+ switch self {
+ case .commit(let url): return url
+ case .compareTip(let url): return url
+ case .tagged(let url): return url
+ }
+ }
+
+ var label: String {
+ switch (self) {
+ case .commit: return "View GitHub Commit"
+ case .compareTip: return "Changes Since This Tip Release"
+ case .tagged: return "View Release Notes"
+ }
+ }
}
struct Error {
diff --git a/macos/Tests/Update/ReleaseNotesTests.swift b/macos/Tests/Update/ReleaseNotesTests.swift
new file mode 100644
index 000000000..b029fa6bc
--- /dev/null
+++ b/macos/Tests/Update/ReleaseNotesTests.swift
@@ -0,0 +1,130 @@
+import Testing
+import Foundation
+@testable import Ghostty
+
+struct ReleaseNotesTests {
+ /// Test tagged release (semantic version)
+ @Test func testTaggedRelease() async throws {
+ let notes = UpdateState.ReleaseNotes(
+ displayVersionString: "1.2.3",
+ currentCommit: nil
+ )
+
+ #expect(notes != nil)
+ if case .tagged(let url) = notes {
+ #expect(url.absoluteString == "https://ghostty.org/docs/install/release-notes/1-2-3")
+ #expect(notes?.label == "View Release Notes")
+ } else {
+ Issue.record("Expected tagged case")
+ }
+ }
+
+ /// Test tip release comparison with current commit
+ @Test func testTipReleaseComparison() async throws {
+ let notes = UpdateState.ReleaseNotes(
+ displayVersionString: "tip-abc1234",
+ currentCommit: "def5678"
+ )
+
+ #expect(notes != nil)
+ if case .compareTip(let url) = notes {
+ #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234")
+ #expect(notes?.label == "Changes Since This Tip Release")
+ } else {
+ Issue.record("Expected compareTip case")
+ }
+ }
+
+ /// Test tip release without current commit
+ @Test func testTipReleaseWithoutCurrentCommit() async throws {
+ let notes = UpdateState.ReleaseNotes(
+ displayVersionString: "tip-abc1234",
+ currentCommit: nil
+ )
+
+ #expect(notes != nil)
+ if case .commit(let url) = notes {
+ #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234")
+ #expect(notes?.label == "View GitHub Commit")
+ } else {
+ Issue.record("Expected commit case")
+ }
+ }
+
+ /// Test tip release with empty current commit
+ @Test func testTipReleaseWithEmptyCurrentCommit() async throws {
+ let notes = UpdateState.ReleaseNotes(
+ displayVersionString: "tip-abc1234",
+ currentCommit: ""
+ )
+
+ #expect(notes != nil)
+ if case .commit(let url) = notes {
+ #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234")
+ } else {
+ Issue.record("Expected commit case")
+ }
+ }
+
+ /// Test version with full 40-character hash
+ @Test func testFullGitHash() async throws {
+ let notes = UpdateState.ReleaseNotes(
+ displayVersionString: "tip-1234567890abcdef1234567890abcdef12345678",
+ currentCommit: nil
+ )
+
+ #expect(notes != nil)
+ if case .commit(let url) = notes {
+ #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/1234567890abcdef1234567890abcdef12345678")
+ } else {
+ Issue.record("Expected commit case")
+ }
+ }
+
+ /// Test version with no recognizable pattern
+ @Test func testInvalidVersion() async throws {
+ let notes = UpdateState.ReleaseNotes(
+ displayVersionString: "unknown-version",
+ currentCommit: nil
+ )
+
+ #expect(notes == nil)
+ }
+
+ /// Test semantic version with prerelease suffix should not match
+ @Test func testSemanticVersionWithSuffix() async throws {
+ let notes = UpdateState.ReleaseNotes(
+ displayVersionString: "1.2.3-beta",
+ currentCommit: nil
+ )
+
+ // Should not match semantic version pattern, falls back to hash detection
+ #expect(notes == nil)
+ }
+
+ /// Test semantic version with 4 components should not match
+ @Test func testSemanticVersionFourComponents() async throws {
+ let notes = UpdateState.ReleaseNotes(
+ displayVersionString: "1.2.3.4",
+ currentCommit: nil
+ )
+
+ // Should not match pattern
+ #expect(notes == nil)
+ }
+
+ /// Test version string with git hash embedded
+ @Test func testVersionWithEmbeddedHash() async throws {
+ let notes = UpdateState.ReleaseNotes(
+ displayVersionString: "v2024.01.15-abc1234",
+ currentCommit: "def5678"
+ )
+
+ #expect(notes != nil)
+ if case .compareTip(let url) = notes {
+ #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234")
+ } else {
+ Issue.record("Expected compareTip case")
+ }
+ }
+}