[terminal] IJPL-176779 Use EEL file system API for getting files instead of executing 'ls'

GitOrigin-RevId: 591cd542b344b5ea234110385f0a65f4d5ea00ef
This commit is contained in:
Konstantin Hudyakov
2025-09-18 10:51:56 +00:00
committed by intellij-monorepo-bot
parent da424e161f
commit 2dd1540154
12 changed files with 297 additions and 28 deletions
@@ -0,0 +1,15 @@
package com.intellij.terminal.completion.spec
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Experimental
@ApiStatus.NonExtendable
interface ShellFileInfo {
val name: String
val type: Type
@ApiStatus.Experimental
enum class Type {
FILE, DIRECTORY, OTHER
}
}
@@ -27,6 +27,11 @@ interface ShellRuntimeContext {
*/
suspend fun runShellCommand(@Language("ShellScript") command: String): ShellCommandResult
/**
* @param path absolute os-dependent path to the directory.
*/
suspend fun listDirectoryFiles(path: String): List<ShellFileInfo>
/**
* Used to implement custom extensions of [ShellRuntimeContext].
* See the extensions in this [file][org.jetbrains.plugins.terminal.block.completion.spec.getFileSuggestions]
@@ -15,9 +15,11 @@ import com.intellij.terminal.completion.spec.ShellCommandExecutor
import com.intellij.terminal.completion.spec.ShellCommandResult
import com.intellij.util.execution.ParametersListUtil
import kotlinx.coroutines.coroutineScope
import org.jetbrains.annotations.ApiStatus
import kotlin.coroutines.cancellation.CancellationException
internal class ShellCommandExecutorReworked(private val eelDescriptor: EelDescriptor) : ShellCommandExecutor {
@ApiStatus.Internal
class ShellCommandExecutorReworked(private val eelDescriptor: EelDescriptor) : ShellCommandExecutor {
override suspend fun runShellCommand(directory: String, command: String): ShellCommandResult {
val commandList = ParametersListUtil.parse(command)
val commandName = commandList.firstOrNull() ?: return emptyResult()
@@ -0,0 +1,54 @@
package com.intellij.terminal.frontend.completion
import com.intellij.openapi.diagnostic.logger
import com.intellij.platform.eel.EelDescriptor
import com.intellij.platform.eel.fs.EelFileInfo
import com.intellij.platform.eel.fs.listDirectoryWithAttrs
import com.intellij.platform.eel.getOrNull
import com.intellij.platform.eel.path.EelPath
import com.intellij.platform.eel.path.EelPathException
import com.intellij.terminal.completion.spec.ShellFileInfo
import kotlinx.coroutines.CancellationException
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.plugins.terminal.block.completion.spec.impl.ShellFileInfoImpl
import org.jetbrains.plugins.terminal.block.completion.spec.impl.ShellFileSystemSupport
@ApiStatus.Internal
class ShellFileSystemSupportImpl(private val eelDescriptor: EelDescriptor) : ShellFileSystemSupport {
override suspend fun listDirectoryFiles(path: String): List<ShellFileInfo> {
val eelPath = try {
EelPath.parse(path, eelDescriptor)
}
catch (_: EelPathException) {
return emptyList()
}
return try {
val eelApi = eelDescriptor.toEelApi()
val result = eelApi.fs.listDirectoryWithAttrs(eelPath).doNotResolve().eelIt()
val files = result.getOrNull() ?: emptyList()
files.map { (name, info) ->
ShellFileInfoImpl(name, info.type.toShellFileType())
}
}
catch (e: CancellationException) {
throw e
}
catch (e: Exception) {
LOG.error("Failed to get child files for path: $path", e)
emptyList()
}
}
private fun EelFileInfo.Type.toShellFileType(): ShellFileInfo.Type {
return when (this) {
is EelFileInfo.Type.Regular -> ShellFileInfo.Type.FILE
is EelFileInfo.Type.Directory -> ShellFileInfo.Type.DIRECTORY
else -> ShellFileInfo.Type.OTHER
}
}
companion object {
private val LOG = logger<ShellFileSystemSupportImpl>()
}
}
@@ -5,7 +5,6 @@ import com.intellij.openapi.project.Project
import com.intellij.platform.eel.EelDescriptor
import com.intellij.terminal.completion.ShellRuntimeContextProvider
import com.intellij.terminal.completion.spec.ShellRuntimeContext
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.plugins.terminal.block.completion.TerminalCompletionUtil.toShellName
import org.jetbrains.plugins.terminal.block.completion.spec.IS_REWORKED_KEY
import org.jetbrains.plugins.terminal.block.completion.spec.PROJECT_KEY
@@ -19,6 +18,7 @@ internal class ShellRuntimeContextProviderReworkedImpl(
eelDescriptor: EelDescriptor,
) : ShellRuntimeContextProvider {
private val shellCommandExecutor = ShellCommandExecutorReworked(eelDescriptor)
private val fileSystemSupport = ShellFileSystemSupportImpl(eelDescriptor)
override fun getContext(typedPrefix: String): ShellRuntimeContext {
return ShellRuntimeContextImpl(
@@ -26,6 +26,7 @@ internal class ShellRuntimeContextProviderReworkedImpl(
typedPrefix,
ShellType.ZSH.toShellName(),
shellCommandExecutor,
fileSystemSupport,
).apply {
putUserData(PROJECT_KEY, project)
putUserData(IS_REWORKED_KEY, true)
@@ -51,11 +51,12 @@ object ShellDataGenerators {
replacementIndexDelta: Int = 0,
): List<ShellCompletionSuggestion> {
val path = getParentPath(pathPrefix)
val files: List<String> = context.getChildFiles(path, onlyDirectories)
val files: List<ShellFileInfo> = context.getChildFiles(path, onlyDirectories)
val prefixReplacementIndex = path.length + (if (isStartWithQuote(context.typedPrefix)) 1 else 0) + replacementIndexDelta
val suggestions = files.flatMap {
val type = if (it.endsWith(File.separatorChar)) ShellSuggestionType.FOLDER else ShellSuggestionType.FILE
val suggestion = ShellCompletionSuggestion(name = it, type = type, prefixReplacementIndex = prefixReplacementIndex)
val type = if (it.type == ShellFileInfo.Type.DIRECTORY) ShellSuggestionType.FOLDER else ShellSuggestionType.FILE
val name = it.name + if (type == ShellSuggestionType.FOLDER) File.separator else ""
val suggestion = ShellCompletionSuggestion(name, type = type, prefixReplacementIndex = prefixReplacementIndex)
if (type == ShellSuggestionType.FILE) {
listOf(suggestion)
}
@@ -63,7 +64,7 @@ object ShellDataGenerators {
// Directory suggestion has a trailing file separator, but suggestion without it is also valid.
// It is needed for the parser to consider it as a valid suggestion and not mark it as something unknown.
val hiddenSuggestion = ShellCompletionSuggestion(
name = it.removeSuffix(File.separator),
name = it.name,
type = ShellSuggestionType.FOLDER,
prefixReplacementIndex = prefixReplacementIndex,
isHidden = true
@@ -5,10 +5,14 @@ import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.io.FileUtil
import com.intellij.terminal.completion.spec.ShellFileInfo
import com.intellij.terminal.completion.spec.ShellRuntimeContext
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.plugins.terminal.block.completion.spec.impl.ShellFileInfoImpl
import org.jetbrains.plugins.terminal.block.session.ShellIntegrationFunctions.GET_DIRECTORY_FILES
import java.io.File
import java.nio.file.InvalidPathException
import kotlin.io.path.Path
@get:ApiStatus.Experimental
val ShellRuntimeContext.project: Project
@@ -26,8 +30,8 @@ val IS_REWORKED_KEY: Key<Boolean> = Key.create("isReworked")
/**
* Returns the list of [path] child file names.
* [path] can be either an absolute path or relative path.
* In case of relative path, it is related to [ShellRuntimeContext.currentDirectory].
* [path] can be either an absolute path, relative path or home-relative path like `~/abc`.
* In case of the relative path, it is related to [ShellRuntimeContext.currentDirectory].
*
* Use [ShellDataGenerators.getParentPath] utility to get the right [path] from the user typed prefix.
*/
@@ -35,7 +39,7 @@ val IS_REWORKED_KEY: Key<Boolean> = Key.create("isReworked")
suspend fun ShellRuntimeContext.getChildFiles(
path: String,
onlyDirectories: Boolean = false,
): List<String> {
): List<ShellFileInfo> {
if (isReworkedTerminal) {
return getChildFilesReworked(path, onlyDirectories)
}
@@ -44,26 +48,30 @@ suspend fun ShellRuntimeContext.getChildFiles(
}
}
private suspend fun ShellRuntimeContext.getChildFilesReworked(path: String, onlyDirectories: Boolean): List<String> {
private suspend fun ShellRuntimeContext.getChildFilesReworked(path: String, onlyDirectories: Boolean): List<ShellFileInfo> {
check(isReworkedTerminal)
val adjustedPath = FileUtil.expandUserHome(path.ifEmpty { "." })
val command = "ls -1ap $adjustedPath"
val result = runShellCommand(command)
if (result.exitCode != 0) {
// it is a regular case: the user entered an invalid path
val adjustedPath = FileUtil.expandUserHome(path)
val absolutePath = try {
val nioPath = Path(adjustedPath)
if (nioPath.isAbsolute) {
nioPath.toString()
}
else {
Path(currentDirectory, path).toString()
}
}
catch (_: InvalidPathException) {
return emptyList()
}
val separator = File.separatorChar
return result.output.splitToSequence("\n")
.filter { it.isNotBlank() }
.filter { !onlyDirectories || it.endsWith(separator) }
// do not suggest './' and '../' choices, there is no need to show them in the completion popup.
.filter { it != ".$separator" && it != "..$separator" }
val files = listDirectoryFiles(absolutePath)
return files.asSequence()
.filter { !onlyDirectories || it.type == ShellFileInfo.Type.DIRECTORY }
.toList()
}
private suspend fun ShellRuntimeContext.getChildFilesExp(path: String, onlyDirectories: Boolean): List<String> {
private suspend fun ShellRuntimeContext.getChildFilesExp(path: String, onlyDirectories: Boolean): List<ShellFileInfo> {
check(!isReworkedTerminal)
val adjustedPath = path.ifEmpty { "." }
@@ -79,5 +87,9 @@ private suspend fun ShellRuntimeContext.getChildFilesExp(path: String, onlyDirec
.filter { !onlyDirectories || it.endsWith(separator) }
// do not suggest './' and '../' directories if the user already typed some path
.filter { path.isEmpty() || (it != ".$separator" && it != "..$separator") }
.map {
val type = if (it.endsWith(separator)) ShellFileInfo.Type.DIRECTORY else ShellFileInfo.Type.FILE
ShellFileInfoImpl(it.removeSuffix(separator.toString()), type)
}
.toList()
}
@@ -0,0 +1,11 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.terminal.block.completion.spec.impl
import com.intellij.terminal.completion.spec.ShellFileInfo
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
data class ShellFileInfoImpl(
override val name: String,
override val type: ShellFileInfo.Type,
) : ShellFileInfo
@@ -0,0 +1,13 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.terminal.block.completion.spec.impl
import com.intellij.terminal.completion.spec.ShellFileInfo
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
interface ShellFileSystemSupport {
/**
* @param path absolute os-dependent path to the directory.
*/
suspend fun listDirectoryFiles(path: String): List<ShellFileInfo>
}
@@ -2,10 +2,7 @@
package org.jetbrains.plugins.terminal.block.completion.spec.impl
import com.intellij.openapi.util.UserDataHolderBase
import com.intellij.terminal.completion.spec.ShellCommandExecutor
import com.intellij.terminal.completion.spec.ShellCommandResult
import com.intellij.terminal.completion.spec.ShellName
import com.intellij.terminal.completion.spec.ShellRuntimeContext
import com.intellij.terminal.completion.spec.*
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
@@ -14,12 +11,18 @@ class ShellRuntimeContextImpl(
override val typedPrefix: String,
override val shellName: ShellName,
private val generatorCommandsRunner: ShellCommandExecutor,
private val fileSystemSupport: ShellFileSystemSupport? = null,
) : ShellRuntimeContext, UserDataHolderBase() {
override suspend fun runShellCommand(command: String): ShellCommandResult {
return generatorCommandsRunner.runShellCommand(currentDirectory, command)
}
override suspend fun listDirectoryFiles(path: String): List<ShellFileInfo> {
return fileSystemSupport?.listDirectoryFiles(path)
?: error("Supported only in Reworked Terminal")
}
override fun toString(): String {
return "ShellRuntimeContextImpl(currentDirectory='$currentDirectory', typedPrefix='$typedPrefix')"
}
@@ -24,9 +24,9 @@ internal fun cdCommandSpec() = ShellCommandSpec("cd") {
val directories = context.getChildFiles(path, onlyDirectories = true)
val prefixReplacementIndex = path.length + if (context.typedPrefix.startsWith('"')) 1 else 0
val suggestions = directories.flatMap {
val suggestion = ShellCompletionSuggestion(name = it, type = ShellSuggestionType.FOLDER, prefixReplacementIndex = prefixReplacementIndex)
val suggestion = ShellCompletionSuggestion(name = it.name + File.separator, type = ShellSuggestionType.FOLDER, prefixReplacementIndex = prefixReplacementIndex)
val hiddenSuggestion = ShellCompletionSuggestion(
name = it.removeSuffix(File.separator),
name = it.name,
type = ShellSuggestionType.FOLDER,
prefixReplacementIndex = prefixReplacementIndex,
isHidden = true
@@ -0,0 +1,152 @@
package com.intellij.terminal.tests.reworked.frontend.completion
import com.intellij.openapi.util.Disposer
import com.intellij.platform.eel.provider.LocalEelDescriptor
import com.intellij.terminal.completion.spec.ShellFileInfo
import com.intellij.terminal.completion.spec.ShellFileInfo.Type.DIRECTORY
import com.intellij.terminal.completion.spec.ShellFileInfo.Type.FILE
import com.intellij.terminal.completion.spec.ShellName
import com.intellij.terminal.frontend.completion.ShellCommandExecutorReworked
import com.intellij.terminal.frontend.completion.ShellFileSystemSupportImpl
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.testFramework.utils.io.createDirectory
import com.intellij.testFramework.utils.io.createFile
import com.intellij.testFramework.utils.io.deleteRecursively
import com.intellij.util.SystemProperties
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import org.jetbrains.plugins.terminal.block.completion.spec.IS_REWORKED_KEY
import org.jetbrains.plugins.terminal.block.completion.spec.getChildFiles
import org.jetbrains.plugins.terminal.block.completion.spec.impl.ShellFileInfoImpl
import org.jetbrains.plugins.terminal.block.completion.spec.impl.ShellRuntimeContextImpl
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import java.io.File
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.createTempDirectory
import kotlin.io.path.deleteIfExists
import kotlin.io.path.name
/**
* Test for [org.jetbrains.plugins.terminal.block.completion.spec.getChildFiles] in Reworked Terminal.
*/
@RunWith(JUnit4::class)
internal class TerminalCompletionFilesCalculationTest : BasePlatformTestCase() {
@Test
fun `get child files by absolute path`() {
val root = createTempDirStructure()
val actualFiles = getChildFiles(root.toString(), root.toString())
val expectedFiles = listOf(
fileInfo("file1.txt", FILE),
fileInfo(".file2", FILE),
fileInfo("file with spaces", FILE),
fileInfo("dir1", DIRECTORY),
fileInfo("dir with spaces", DIRECTORY),
fileInfo("dir2", DIRECTORY),
)
assertThat(actualFiles).hasSameElementsAs(expectedFiles)
}
@Test
fun `get child files by relative path`() {
val root = createTempDirStructure()
val actualFiles = getChildFiles(root.toString(), "")
val expectedFiles = listOf(
fileInfo("file1.txt", FILE),
fileInfo(".file2", FILE),
fileInfo("file with spaces", FILE),
fileInfo("dir1", DIRECTORY),
fileInfo("dir with spaces", DIRECTORY),
fileInfo("dir2", DIRECTORY),
)
assertThat(actualFiles).hasSameElementsAs(expectedFiles)
}
@Test
fun `get child files from nested dir by a relative path`() {
val root = createTempDirStructure()
val actualFiles = getChildFiles(root.toString(), "dir2")
val expectedFiles = listOf(
fileInfo("nestedFile1.txt", FILE),
fileInfo(".nestedFile2", FILE),
fileInfo("nestedFile with spaces", FILE),
fileInfo("nestedDir", DIRECTORY),
)
assertThat(actualFiles).hasSameElementsAs(expectedFiles)
}
@Test
fun `get child files from nested dir by a relative path with trailing path separator`() {
val root = createTempDirStructure()
val actualFiles = getChildFiles(root.toString(), "dir2" + File.separator)
val expectedFiles = listOf(
fileInfo("nestedFile1.txt", FILE),
fileInfo(".nestedFile2", FILE),
fileInfo("nestedFile with spaces", FILE),
fileInfo("nestedDir", DIRECTORY),
)
assertThat(actualFiles).hasSameElementsAs(expectedFiles)
}
@Test
fun `get child files from home related path`() {
val tempFile = createTempFileInUserHome()
val actualFiles = getChildFiles("/", "~")
assertThat(actualFiles).contains(fileInfo(tempFile.name, FILE))
}
@Test
fun `get child files from home related path with trailing path separator`() {
val tempFile = createTempFileInUserHome()
val actualFiles = getChildFiles("/", "~" + File.separator)
assertThat(actualFiles).contains(fileInfo(tempFile.name, FILE))
}
private fun getChildFiles(currentDirectory: String, path: String): List<ShellFileInfo> = runBlocking {
val eelDescriptor = LocalEelDescriptor
val context = ShellRuntimeContextImpl(
currentDirectory,
typedPrefix = path,
ShellName("test"),
ShellCommandExecutorReworked(eelDescriptor),
ShellFileSystemSupportImpl(eelDescriptor)
)
context.putUserData(IS_REWORKED_KEY, true)
context.getChildFiles(path)
}
private fun createTempDirStructure(): Path {
val root = createTempDirectory("terminal-completion")
Disposer.register(testRootDisposable) { root.deleteRecursively() }
return root.apply {
createFile("file1.txt")
createFile(".file2")
createFile("file with spaces")
createDirectory("dir1")
createDirectory("dir with spaces")
createDirectory("dir2").apply {
createFile("nestedFile1.txt")
createFile(".nestedFile2")
createFile("nestedFile with spaces")
createDirectory("nestedDir")
}
}
}
private fun createTempFileInUserHome(): Path {
val userHome = Path(SystemProperties.getUserHome())
val tempFile = userHome.createFile("terminal-completion-test-file")
Disposer.register(testRootDisposable) { tempFile.deleteIfExists() }
return tempFile
}
private fun fileInfo(name: String, type: ShellFileInfo.Type): ShellFileInfo {
return ShellFileInfoImpl(name, type)
}
}