feat(partnerctl): add path prompt with completion and validation

This commit is contained in:
2026-05-03 19:27:07 +08:00
parent 6f48c36f67
commit 729700ceb7
2 changed files with 83 additions and 0 deletions

View File

@@ -1,11 +1,16 @@
package work.slhaf.partner.ctl.ui
import org.jline.builtins.Completers.DirectoriesCompleter
import org.jline.builtins.Completers.FilesCompleter
import org.jline.reader.EndOfFileException
import org.jline.reader.LineReader
import org.jline.reader.LineReaderBuilder
import org.jline.reader.UserInterruptException
import org.jline.terminal.Terminal
import org.jline.terminal.TerminalBuilder
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
class Prompt private constructor(
private val terminal: Terminal,
@@ -88,6 +93,52 @@ class Prompt private constructor(
}
}
fun askPath(
label: String,
defaultValue: Path? = null,
required: Boolean = true,
mustExist: Boolean = false,
directoryOnly: Boolean = false,
fileOnly: Boolean = false,
currentDir: Path = Paths.get(System.getProperty("user.dir") ?: "."),
): Path {
require(!(directoryOnly && fileOnly)) { "directoryOnly and fileOnly cannot both be true" }
val normalizedCurrentDir = currentDir.toAbsolutePath().normalize()
val pathReader = LineReaderBuilder.builder()
.terminal(terminal)
.completer(
if (directoryOnly) {
DirectoriesCompleter(normalizedCurrentDir)
} else {
FilesCompleter(normalizedCurrentDir)
}
)
.build()
val suffix = defaultValue?.let { " [$it]" } ?: ""
while (true) {
val input = readLine(pathReader, "$label$suffix: ").trim()
val rawValue = when {
input.isNotEmpty() -> input
defaultValue != null -> defaultValue.toString()
!required -> ""
else -> {
error("This value is required.")
continue
}
}
if (rawValue.isBlank() && !required) {
return Paths.get("")
}
val path = expandPath(rawValue)
val validationError = validatePath(path, mustExist, directoryOnly, fileOnly) ?: return path
error(validationError)
}
}
fun <T> select(
label: String,
choices: List<Choice<T>>,
@@ -373,6 +424,10 @@ class Prompt private constructor(
}
private fun readLine(prompt: String): String {
return readLine(reader, prompt)
}
private fun readLine(reader: LineReader, prompt: String): String {
return try {
reader.readLine(prompt)
} catch (_: UserInterruptException) {
@@ -382,6 +437,28 @@ class Prompt private constructor(
}
}
private fun expandPath(value: String): Path {
return when {
value == "~" -> Paths.get(System.getProperty("user.home") ?: ".")
value.startsWith("~/") -> Paths.get(System.getProperty("user.home") ?: ".", value.substring(2))
else -> Paths.get(value)
}.toAbsolutePath().normalize()
}
private fun validatePath(
path: Path,
mustExist: Boolean,
directoryOnly: Boolean,
fileOnly: Boolean,
): String? {
return when {
mustExist && !Files.exists(path) -> "Path does not exist: $path"
directoryOnly && Files.exists(path) && !Files.isDirectory(path) -> "Path is not a directory: $path"
fileOnly && Files.exists(path) && !Files.isRegularFile(path) -> "Path is not a regular file: $path"
else -> null
}
}
private fun parseIndexList(input: String, size: Int): Set<Int>? {
if (input.isBlank()) return emptySet()

View File

@@ -111,6 +111,12 @@ fun main() {
)
prompt.info("Selected modules = ${modules.joinToString()}")
prompt.section("Ask path")
val askPath = prompt.askPath(
label = "Ask path",
)
prompt.info("Ask path = $askPath")
prompt.section("Done")
prompt.success("Prompt demo completed.")
} catch (_: work.slhaf.partner.ctl.ui.PromptCancelledException) {