diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt index 399f9de9..925e67c6 100644 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt @@ -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 select( label: String, choices: List>, @@ -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? { if (input.isBlank()) return emptySet() diff --git a/PartnerCtl/src/test/java/experimental/PromptDemo.kt b/PartnerCtl/src/test/java/experimental/PromptDemo.kt index 75979bb8..5d274ea4 100644 --- a/PartnerCtl/src/test/java/experimental/PromptDemo.kt +++ b/PartnerCtl/src/test/java/experimental/PromptDemo.kt @@ -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) {