mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-10-11 04:16:55 +00:00
macos: show release notes link
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
130
macos/Tests/Update/ReleaseNotesTests.swift
Normal file
130
macos/Tests/Update/ReleaseNotesTests.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user