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
6 changes: 3 additions & 3 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ jobs:
node-version: [24.x]
steps:
- name: "📥 Checkout repository"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: "☕️ Setup JDK"
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: 'liberica'
java-version: ${{ matrix.java }}
- name: "🐘 Setup Gradle"
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ jobs:
java: ['25']
steps:
- name: "📥 Checkout repository"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: "☕️ Setup JDK"
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: 'liberica'
java-version: ${{ matrix.java }}
- name: "🐘 Setup Gradle"
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }}
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
24.16.0
24.20.0
1,034 changes: 494 additions & 540 deletions app/launch/package-lock.json

Large diffs are not rendered by default.

32 changes: 16 additions & 16 deletions app/launch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,32 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@materializecss/materialize": "^2.3.2",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@mui/x-tree-view": "^9.0.4",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@mui/x-tree-view": "^9.12.0",
"lodash": "^4.18.1",
"react": "^19.2.5",
"react-dom": "^19.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-syntax-highlighter": "^16.1.1",
"zustand": "^5.0.14"
"zustand": "^5.0.15"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@vitejs/plugin-react": "^6.0.3",
"baseline-browser-mapping": "^2.10.0",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@vitejs/plugin-react": "^6.1.1",
"baseline-browser-mapping": "^2.11.20",
"cross-env": "^10.1.0",
"eslint": "^9.39.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.7.0",
"jsdom": "^29.1.1",
"prettier": "^3.9.5",
"vite": "^8.1.4",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.12.0",
"jsdom": "^30.0.1",
"prettier": "^3.9.6",
"vite": "^8.2.2",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^4.1.10"
"vitest": "^4.1.11"
},
"scripts": {
"start": "vite",
Expand Down
109 changes: 109 additions & 0 deletions app/launch/src/helpers/__tests__/versionCompare.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'

import { compareVersions, sortByVersion } from '../versionCompare'

const sorted = (versions) => [...versions].sort(compareVersions)

describe('compareVersions', () => {
it('orders a realistic feed oldest first', () => {
// The order the version radio group is expected to render
expect(
sorted([
'7.2.3',
'7.2.4-SNAPSHOT',
'8.0.0-M6',
'8.0.0-SNAPSHOT',
'7.1.6',
'7.1.7-SNAPSHOT',
'7.0.6',
])
).toEqual([
'7.0.6',
'7.1.6',
'7.1.7-SNAPSHOT',
'7.2.3',
'7.2.4-SNAPSHOT',
'8.0.0-M6',
'8.0.0-SNAPSHOT',
])
})

it('compares release parts numerically, not as text', () => {
expect(sorted(['7.10.0', '7.9.0', '7.2.0'])).toEqual([
'7.2.0',
'7.9.0',
'7.10.0',
])
expect(compareVersions('10.0.0', '9.0.0')).toBeGreaterThan(0)
})

it('treats missing release parts as zero', () => {
expect(compareVersions('7.1', '7.1.0')).toBe(0)
expect(compareVersions('7', '7.0.1')).toBeLessThan(0)
})

it('sorts a prerelease before its own release', () => {
expect(compareVersions('8.0.0-SNAPSHOT', '8.0.0')).toBeLessThan(0)
expect(compareVersions('8.0.0', '8.0.0-M6')).toBeGreaterThan(0)
})

it('sorts a prerelease after the previous release', () => {
expect(compareVersions('7.1.7-SNAPSHOT', '7.1.6')).toBeGreaterThan(0)
})

it('orders milestones by their number', () => {
expect(sorted(['8.0.0-M10', '8.0.0-M2', '8.0.0-M6'])).toEqual([
'8.0.0-M2',
'8.0.0-M6',
'8.0.0-M10',
])
})

it('orders milestones ahead of release candidates and snapshots', () => {
expect(sorted(['8.0.0-SNAPSHOT', '8.0.0-RC1', '8.0.0-M6'])).toEqual([
'8.0.0-M6',
'8.0.0-RC1',
'8.0.0-SNAPSHOT',
])
})

it('ignores build metadata', () => {
expect(compareVersions('7.2.3+build.9', '7.2.3')).toBe(0)
})

it('sorts unparseable versions last without reordering them', () => {
expect(sorted(['nightly', '8.0.0-M6', 'unknown', '7.0.6'])).toEqual([
'7.0.6',
'8.0.0-M6',
'nightly',
'unknown',
])
})

it('tolerates missing values', () => {
expect(compareVersions(undefined, '7.0.6')).toBeGreaterThan(0)
expect(compareVersions(null, undefined)).toBe(0)
})
})

describe('sortByVersion', () => {
it('orders option objects by their version', () => {
const options = [
{ key: 'RELEASE', version: '7.2.3' },
{ key: 'NEXT', version: '8.0.0-M6' },
{ key: 'OLDER', version: '7.0.6' },
]

expect(sortByVersion(options).map((o) => o.key)).toEqual([
'OLDER',
'RELEASE',
'NEXT',
])
})

it('does not mutate the array it was given', () => {
const options = [{ version: '8.0.0' }, { version: '7.0.6' }]
sortByVersion(options)
expect(options.map((o) => o.version)).toEqual(['8.0.0', '7.0.6'])
})
})
127 changes: 127 additions & 0 deletions app/launch/src/helpers/versionCompare.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
const NUMERIC = /^\d+$/

/**
* Split a version into its numeric release parts and its prerelease
* identifiers. Build metadata is discarded because it never affects precedence.
*
* @param {String} version e.g. "7.1.7-SNAPSHOT"
* @return {Object|null} {numbers, prerelease}, or null when unparseable
*/
function parseVersion(version) {
const text = String(version ?? '').trim()
if (!text) return null

const core = text.split('+')[0]
const separator = core.indexOf('-')
const release = separator === -1 ? core : core.slice(0, separator)
const prerelease = separator === -1 ? '' : core.slice(separator + 1)

const numbers = release.split('.')
if (!numbers.every((part) => NUMERIC.test(part))) {
return null
}

return {
numbers: numbers.map(Number),
prerelease: prerelease ? prerelease.split('.') : [],
}
}

/**
* Break an identifier into its alternating letter and digit runs so that
* embedded numbers compare numerically: "M10" sorts after "M6" rather than
* before it, which a plain string comparison would get wrong.
*
* @param {String} identifier
* @return {Array<String>}
*/
function chunk(identifier) {
return identifier.match(/\d+|\D+/g) ?? [identifier]
}

function compareNumbers(left, right) {
const length = Math.max(left.length, right.length)
for (let i = 0; i < length; i++) {
// A missing part is an implied zero, so 7.1 and 7.1.0 are equal
const diff = (left[i] ?? 0) - (right[i] ?? 0)
if (diff !== 0) return diff < 0 ? -1 : 1
}
return 0
}

function compareIdentifier(left, right) {
const leftChunks = chunk(left)
const rightChunks = chunk(right)
const length = Math.max(leftChunks.length, rightChunks.length)

for (let i = 0; i < length; i++) {
const a = leftChunks[i]
const b = rightChunks[i]
if (a === undefined) return -1
if (b === undefined) return 1
if (a === b) continue

if (NUMERIC.test(a) && NUMERIC.test(b)) {
return Number(a) < Number(b) ? -1 : 1
}
// Compare case insensitively so "m6" and "M6" land next to each other
const insensitive = a.toUpperCase().localeCompare(b.toUpperCase())
if (insensitive !== 0) return insensitive < 0 ? -1 : 1
return a < b ? -1 : 1
}
return 0
}

function comparePrerelease(left, right) {
// A release outranks any of its own prereleases: 8.0.0 is newer than 8.0.0-M6
if (!left.length && !right.length) return 0
if (!left.length) return 1
if (!right.length) return -1

const length = Math.max(left.length, right.length)
for (let i = 0; i < length; i++) {
const a = left[i]
const b = right[i]
if (a === undefined) return -1
if (b === undefined) return 1

const diff = compareIdentifier(a, b)
if (diff !== 0) return diff
}
return 0
}

/**
* Compare two Grails version strings oldest first, so a sorted list reads
* 7.0.6, 7.1.6, 7.1.7-SNAPSHOT, 7.2.3, 7.2.4-SNAPSHOT, 8.0.0-M6, 8.0.0-SNAPSHOT.
*
* Versions that cannot be parsed sort last, keeping their relative order,
* rather than scattering through the list.
*
* @param {String} a
* @param {String} b
* @return {Number} negative, zero or positive, per Array#sort
*/
export function compareVersions(a, b) {
const left = parseVersion(a)
const right = parseVersion(b)

if (!left && !right) return 0
if (!left) return 1
if (!right) return -1

const numbers = compareNumbers(left.numbers, right.numbers)
if (numbers !== 0) return numbers

return comparePrerelease(left.prerelease, right.prerelease)
}

/**
* Sort loaded version options oldest first. Returns a new array.
*
* @param {Array<Object>} options Objects carrying a `version` string
* @return {Array<Object>}
*/
export function sortByVersion(options) {
return [...options].sort((a, b) => compareVersions(a?.version, b?.version))
}
14 changes: 9 additions & 5 deletions app/launch/src/micronaut/MicronautStarterSDK.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CacheApi, SessionStorageAdapter } from '../helpers/Cache'
import { sortByVersion } from '../helpers/versionCompare'
import { VERSION_FEED_URL } from './constants'
import { CreateCommand } from './CreateCommand'
import {FeatureCommand} from "./FeatureCommand";
Expand Down Expand Up @@ -242,10 +243,13 @@ export class MicronautStarterSDK {
const maybeLoadVersion = (version) =>
this.loadVersion(version).catch((_i) => null)

return (
await Promise.all(
versions.sort((a, b) => a.order - b.order).map(maybeLoadVersion)
)
).filter((i) => i)
const loaded = (await Promise.all(versions.map(maybeLoadVersion))).filter(
(i) => i
)

// The feed's `order` is the publishing channel order (RELEASE, SNAPSHOT,
// NEXT...), which says nothing about how the versions themselves relate.
// Sort by the Grails version each channel actually resolved to.
return sortByVersion(loaded)
}
}
Loading