Skip to content

Commit dccc948

Browse files
committed
feat: always open the generated project after the wizard
The next-step menu offers the IDE only when an IntelliJ IDEA installation is found. The file manager is no longer a menu entry but the default: the project folder opens whenever the IDE was not chosen, is not installed, or failed to start, so a finished project is always shown somewhere. ProjectLauncher.open now reports whether the launcher actually started, which is what makes the fallback possible. A command that exits with an error right away counts as a failure; only the IDE launcher's exit code is checked, since explorer.exe reports a non-zero code even when the window opens. Nothing opens in non-interactive or piped runs, as before.
1 parent 320fb8e commit dccc948

3 files changed

Lines changed: 147 additions & 29 deletions

File tree

‎src/main/kotlin/io/jmix/cli/NewCommand.kt‎

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -636,33 +636,50 @@ class NewCommand : CliktCommand(name = "new") {
636636
// abort with a non-zero exit after the project was already created.
637637
if (!interactive || !terminal.terminalInfo.inputInteractive) return
638638
val runTask = runTaskFor(info)
639-
val open = ProjectLauncher.openCommand(info.projectDir)
640-
val openChoice = "Open the project in ${open.opener.displayName}"
639+
// Offered only when an IDE is actually installed; the file manager is
640+
// never a question — it is the fallback below.
641+
val ideaOpen = ProjectLauncher.ideaOpenCommand(info.projectDir)
642+
val openChoice = ideaOpen?.let { "Open the project in ${it.opener.displayName}" }
641643
// Composite aggregator projects have no runnable task — offer only the open.
642644
val runChoice = runTask?.let { "Run the application (./gradlew $it)" }
643645

644646
val entries = listOfNotNull(openChoice, runChoice).map { SelectList.Entry(it) }
645-
val picked = when (val answer = prompts.chooseMany("What's next?", entries)) {
646-
// No arrow-key widget: one yes/no question per option instead.
647-
null -> listOfNotNull(
648-
openChoice.takeIf { prompts.askYesNo("$openChoice?", default = false).requireValue() },
649-
runChoice?.takeIf { prompts.askYesNo("$runChoice?", default = false).requireValue() },
650-
)
651-
is Answer.Back -> emptyList()
652-
is Answer.Value -> answer.value
647+
val picked = when {
648+
entries.isEmpty() -> emptyList()
649+
else -> when (val answer = prompts.chooseMany("What's next?", entries)) {
650+
// No arrow-key widget: one yes/no question per option instead.
651+
null -> listOfNotNull(
652+
openChoice?.takeIf { prompts.askYesNo("$it?", default = false).requireValue() },
653+
runChoice?.takeIf { prompts.askYesNo("$it?", default = false).requireValue() },
654+
)
655+
is Answer.Back -> emptyList()
656+
is Answer.Value -> answer.value
657+
}
653658
}
654659

655-
if (openChoice in picked) {
656-
terminal.println(gray("Opening the project in ${open.opener.displayName}..."))
657-
ProjectLauncher.open(open) { terminal.println(brightYellow("Warning: $it")) }
658-
}
660+
openProject(info, ideaOpen.takeIf { openChoice != null && openChoice in picked })
659661
if (runChoice != null && runChoice in picked) {
660662
runApplication(info, runTask)
661663
} else if (runTask != null) {
662664
offerJdkInstall(info)
663665
}
664666
}
665667

668+
/**
669+
* Opens the generated project: in the IDE when the user asked for it, and
670+
* in the file manager otherwise — including when the IDE failed to start,
671+
* so a finished project is always shown somewhere.
672+
*/
673+
private fun openProject(info: ProjectCreationInfo, ideaOpen: ProjectLauncher.OpenCommand?) {
674+
val warn: (String) -> Unit = { terminal.println(brightYellow("Warning: $it")) }
675+
if (ideaOpen != null) {
676+
terminal.println(gray("Opening the project in ${ideaOpen.opener.displayName}..."))
677+
if (ProjectLauncher.open(ideaOpen, warn)) return
678+
}
679+
terminal.println(gray("Opening the project folder..."))
680+
ProjectLauncher.open(ProjectLauncher.fileManagerOpenCommand(info.projectDir), warn)
681+
}
682+
666683
private fun runApplication(info: ProjectCreationInfo, runTask: String) {
667684
val javaHome = compatibleJdkHome(info) ?: installJdk(info) ?: run {
668685
terminal.println(brightYellow("Skipping the run — no compatible JDK is available."))

‎src/main/kotlin/io/jmix/cli/env/ProjectLauncher.kt‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,20 @@ object ProjectLauncher {
2323

2424
data class OpenCommand(val opener: Opener, val command: List<String>)
2525

26-
fun openCommand(
26+
/** IDEA launch command, or null when no IDEA installation was found. */
27+
fun ideaOpenCommand(
2728
projectDir: Path,
2829
os: String = System.getProperty("os.name"),
2930
pathEnv: String? = System.getenv("PATH"),
3031
macAppDirs: List<Path> = defaultMacAppDirs(),
31-
): OpenCommand {
32-
ideaCommand(projectDir, os, pathEnv, macAppDirs)?.let { return OpenCommand(Opener.IDEA, it) }
33-
return OpenCommand(Opener.FILE_MANAGER, fileManagerCommand(projectDir, os))
34-
}
32+
): OpenCommand? =
33+
ideaCommand(projectDir, os, pathEnv, macAppDirs)?.let { OpenCommand(Opener.IDEA, it) }
34+
35+
/** Always available: every desktop OS has a file manager. */
36+
fun fileManagerOpenCommand(
37+
projectDir: Path,
38+
os: String = System.getProperty("os.name"),
39+
): OpenCommand = OpenCommand(Opener.FILE_MANAGER, fileManagerCommand(projectDir, os))
3540

3641
fun ideaCommand(projectDir: Path, os: String, pathEnv: String?, macAppDirs: List<Path>): List<String>? {
3742
ideaLauncherOnPath(os, pathEnv)?.let { return listOf(it.toString(), projectDir.toString()) }
@@ -52,16 +57,39 @@ object ProjectLauncher {
5257
fun gradleCommand(task: String, os: String = System.getProperty("os.name")): List<String> =
5358
if (isWindows(os)) listOf("cmd", "/c", "gradlew.bat", task) else listOf("./gradlew", task)
5459

55-
/** Launches [open] detached; the IDE or file manager outlives the CLI. */
56-
fun open(open: OpenCommand, onWarning: (String) -> Unit) {
57-
try {
60+
/**
61+
* Launches [open] detached; the IDE or file manager outlives the CLI.
62+
* Returns false when the command could not start or exited with an error
63+
* right away, so the caller can fall back to another opener.
64+
*/
65+
fun open(open: OpenCommand, onWarning: (String) -> Unit): Boolean {
66+
val process = try {
5867
ProcessBuilder(open.command)
5968
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
6069
.redirectError(ProcessBuilder.Redirect.DISCARD)
6170
.start()
6271
} catch (e: Exception) {
6372
onWarning("Failed to open the project (${open.command.first()}): ${e.message}")
73+
return false
74+
}
75+
// A launcher that exits immediately with an error opened nothing. Only
76+
// the IDE launcher's code is meaningful: explorer.exe reports a non-zero
77+
// code even when the window opens.
78+
if (open.opener != Opener.IDEA) return true
79+
val exited = try {
80+
process.waitFor(OPEN_PROBE_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
81+
} catch (e: InterruptedException) {
82+
Thread.currentThread().interrupt()
83+
return true
6484
}
85+
if (exited && process.exitValue() != 0) {
86+
onWarning(
87+
"${open.opener.displayName} failed to open the project " +
88+
"(${open.command.first()} exited with ${process.exitValue()}).",
89+
)
90+
return false
91+
}
92+
return true
6593
}
6694

6795
/**
@@ -169,6 +197,10 @@ object ProjectLauncher {
169197
private val SERVER_PORT_PROPERTY = Regex("""server\.port\s*=\s*(\d+)""")
170198
private const val DEFAULT_SERVER_PORT = 8080
171199
private const val PORT_PROBE_TIMEOUT_MS = 1000
200+
201+
// Long enough to catch a launcher that fails outright, short enough not to
202+
// stall the wizard when the IDE process keeps running.
203+
private const val OPEN_PROBE_MS = 1200L
172204
private const val APP_POLL_INTERVAL_MS = 1000L
173205

174206
// First runs download Gradle and dependencies, which can take a while.

‎src/test/kotlin/io/jmix/cli/env/ProjectLauncherTest.kt‎

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package io.jmix.cli.env
22

33
import org.junit.jupiter.api.Assertions.assertEquals
4+
import org.junit.jupiter.api.Assertions.assertFalse
45
import org.junit.jupiter.api.Assertions.assertNull
6+
import org.junit.jupiter.api.Assertions.assertTrue
7+
import org.junit.jupiter.api.Assumptions.assumeFalse
58
import org.junit.jupiter.api.Test
69
import org.junit.jupiter.api.io.TempDir
710
import java.nio.file.Files
@@ -94,15 +97,81 @@ class ProjectLauncherTest {
9497

9598
assertEquals(app, ProjectLauncher.macIdeaApp(listOf(tempDir)))
9699

97-
val open = ProjectLauncher.openCommand(projectDir, os = "Mac OS X", pathEnv = "", macAppDirs = listOf(tempDir))
98-
assertEquals(ProjectLauncher.Opener.IDEA, open.opener)
99-
assertEquals(listOf("open", "-a", "$app", "$projectDir"), open.command)
100+
val open = ProjectLauncher.ideaOpenCommand(projectDir, os = "Mac OS X", pathEnv = "", macAppDirs = listOf(tempDir))
101+
assertEquals(ProjectLauncher.Opener.IDEA, open?.opener)
102+
assertEquals(listOf("open", "-a", "$app", "$projectDir"), open?.command)
100103
}
101104

102105
@Test
103-
fun `falls back to file manager when idea is not found`(@TempDir tempDir: Path) {
104-
val open = ProjectLauncher.openCommand(projectDir, os = "Linux", pathEnv = tempDir.toString(), macAppDirs = emptyList())
105-
assertEquals(ProjectLauncher.Opener.FILE_MANAGER, open.opener)
106-
assertEquals(listOf("xdg-open", "$projectDir"), open.command)
106+
fun `no idea command without an installation, and the file manager always has one`(@TempDir tempDir: Path) {
107+
assertNull(
108+
ProjectLauncher.ideaOpenCommand(
109+
projectDir, os = "Linux", pathEnv = tempDir.toString(), macAppDirs = emptyList(),
110+
),
111+
)
112+
113+
val fallback = ProjectLauncher.fileManagerOpenCommand(projectDir, os = "Linux")
114+
assertEquals(ProjectLauncher.Opener.FILE_MANAGER, fallback.opener)
115+
assertEquals(listOf("xdg-open", "$projectDir"), fallback.command)
116+
}
117+
118+
@Test
119+
fun `idea open command is returned when an installation exists`(@TempDir tempDir: Path) {
120+
val launcher = tempDir.resolve("idea")
121+
Files.writeString(launcher, "#!/bin/sh\n")
122+
launcher.toFile().setExecutable(true)
123+
124+
val open = ProjectLauncher.ideaOpenCommand(
125+
projectDir, os = "Linux", pathEnv = tempDir.toString(), macAppDirs = emptyList(),
126+
)
127+
128+
assertEquals(ProjectLauncher.Opener.IDEA, open?.opener)
129+
assertEquals(listOf("$launcher", "$projectDir"), open?.command)
130+
}
131+
132+
@Test
133+
fun `open reports failure when the ide launcher cannot start`() {
134+
val warnings = mutableListOf<String>()
135+
val missing = ProjectLauncher.OpenCommand(
136+
ProjectLauncher.Opener.IDEA, listOf("jmix-no-such-launcher", "$projectDir"),
137+
)
138+
139+
assertFalse(ProjectLauncher.open(missing) { warnings.add(it) })
140+
assertTrue(warnings.isNotEmpty(), "a failed open must warn")
141+
}
142+
143+
@Test
144+
fun `open reports failure when the ide launcher exits with an error`(@TempDir tempDir: Path) {
145+
assumeFalse(System.getProperty("os.name").startsWith("Windows", ignoreCase = true))
146+
val launcher = tempDir.resolve("idea")
147+
Files.writeString(launcher, "#!/bin/sh\nexit 1\n")
148+
launcher.toFile().setExecutable(true)
149+
val failing = ProjectLauncher.OpenCommand(ProjectLauncher.Opener.IDEA, listOf("$launcher", "$projectDir"))
150+
151+
assertFalse(ProjectLauncher.open(failing) {})
152+
}
153+
154+
@Test
155+
fun `open succeeds for a launcher that keeps running`(@TempDir tempDir: Path) {
156+
assumeFalse(System.getProperty("os.name").startsWith("Windows", ignoreCase = true))
157+
// An IDE stays up; the probe must not mistake that for a failure.
158+
val launcher = tempDir.resolve("idea")
159+
Files.writeString(launcher, "#!/bin/sh\nsleep 30\n")
160+
launcher.toFile().setExecutable(true)
161+
val running = ProjectLauncher.OpenCommand(ProjectLauncher.Opener.IDEA, listOf("$launcher", "$projectDir"))
162+
163+
assertTrue(ProjectLauncher.open(running) {})
164+
}
165+
166+
@Test
167+
fun `open ignores the exit code of the file manager`(@TempDir tempDir: Path) {
168+
assumeFalse(System.getProperty("os.name").startsWith("Windows", ignoreCase = true))
169+
// explorer.exe reports a non-zero code even when the window opens.
170+
val opener = tempDir.resolve("open")
171+
Files.writeString(opener, "#!/bin/sh\nexit 1\n")
172+
opener.toFile().setExecutable(true)
173+
val command = ProjectLauncher.OpenCommand(ProjectLauncher.Opener.FILE_MANAGER, listOf("$opener", "$projectDir"))
174+
175+
assertTrue(ProjectLauncher.open(command) {})
107176
}
108177
}

0 commit comments

Comments
 (0)