Skip to content

Commit b6ae7b1

Browse files
authored
feat(amazonq): launch lsp from resolved artifacts and allow user to override via config (#5449)
download implemented in #5387 override UI implemented in #5429 Additionally, use zipfs for extracting zip files to respect original posix attributes on systems where that is relevant
1 parent ac81a2f commit b6ae7b1

File tree

9 files changed

+164
-42
lines changed

9 files changed

+164
-42
lines changed

plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,11 @@ class CodeWhispererConfigurable(private val project: Project) :
7272

7373
textFieldWithBrowseButton(fileChooserDescriptor = fileChooserDescriptor)
7474
.bindText(
75-
{ LspSettings.getInstance().getArtifactPath() },
76-
{ LspSettings.getInstance().setArtifactPath(it.takeIf { v -> v.isNotBlank() }) }
75+
{ LspSettings.getInstance().getArtifactPath().orEmpty() },
76+
{ LspSettings.getInstance().setArtifactPath(it) }
7777
)
7878
.applyToComponent {
79-
emptyText.text = "Choose a file to upload"
79+
emptyText.text = message("executableCommon.auto_managed")
8080
}
8181
.resizableColumn()
8282
.align(Align.FILL)

plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.intellij.openapi.components.serviceIfCreated
1818
import com.intellij.openapi.project.Project
1919
import com.intellij.openapi.util.Disposer
2020
import com.intellij.openapi.util.Key
21+
import com.intellij.openapi.util.SystemInfo
2122
import com.intellij.util.io.await
2223
import kotlinx.coroutines.CoroutineScope
2324
import kotlinx.coroutines.Deferred
@@ -45,6 +46,7 @@ import software.aws.toolkits.core.utils.getLogger
4546
import software.aws.toolkits.core.utils.info
4647
import software.aws.toolkits.core.utils.warn
4748
import software.aws.toolkits.jetbrains.isDeveloperMode
49+
import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager
4850
import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService
4951
import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.DefaultModuleDependenciesService
5052
import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager
@@ -53,6 +55,7 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument.TextDoc
5355
import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders
5456
import software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace.WorkspaceServiceHandler
5557
import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata
58+
import software.aws.toolkits.jetbrains.settings.LspSettings
5659
import java.io.IOException
5760
import java.io.OutputStreamWriter
5861
import java.io.PipedInputStream
@@ -245,8 +248,12 @@ private class AmazonQServerInstance(private val project: Project, private val cs
245248
}
246249

247250
init {
251+
// will cause slow service init, but maybe fine for now. will not block UI since fetch/extract will be under background progress
252+
val artifact = runBlocking { ArtifactManager(project, manifestRange = null).fetchArtifact() }.toAbsolutePath()
253+
val node = if (SystemInfo.isWindows) "node.exe" else "node"
248254
val cmd = GeneralCommandLine(
249-
"amazon-q-lsp",
255+
artifact.resolve(node).toString(),
256+
LspSettings.getInstance().getArtifactPath() ?: artifact.resolve("aws-lsp-codewhisperer.js").toString(),
250257
"--stdio",
251258
"--set-credentials-encryption-key",
252259
)

plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,18 @@ import com.intellij.openapi.util.SystemInfo
77
import com.intellij.openapi.util.text.StringUtil
88
import com.intellij.util.io.DigestUtil
99
import com.intellij.util.system.CpuArch
10+
import software.aws.toolkits.core.utils.ZIP_PROPERTY_POSIX
1011
import software.aws.toolkits.core.utils.createParentDirectories
1112
import software.aws.toolkits.core.utils.exists
13+
import software.aws.toolkits.core.utils.hasPosixFilePermissions
1214
import java.io.FileNotFoundException
13-
import java.io.FileOutputStream
15+
import java.net.URI
16+
import java.nio.file.FileSystems
1417
import java.nio.file.Files
1518
import java.nio.file.Path
1619
import java.nio.file.Paths
1720
import java.nio.file.StandardCopyOption
1821
import java.security.MessageDigest
19-
import java.util.zip.ZipFile
2022
import kotlin.io.path.isDirectory
2123
import kotlin.io.path.listDirectoryEntries
2224

@@ -78,17 +80,20 @@ fun extractZipFile(zipFilePath: Path, destDir: Path) {
7880
}
7981

8082
try {
81-
ZipFile(zipFilePath.toFile()).use { zipFile ->
82-
zipFile.entries()
83-
.asSequence()
84-
.filterNot { it.isDirectory }
85-
.map { zipEntry ->
86-
val destPath = destDir.resolve(zipEntry.name)
87-
destPath.createParentDirectories()
88-
FileOutputStream(destPath.toFile()).use { targetFile ->
89-
zipFile.getInputStream(zipEntry).copyTo(targetFile)
83+
FileSystems.newFileSystem(
84+
// jar prefix due to potentially ambiguous resolution to wrong fs impl for zipfs on windows
85+
URI("jar:${zipFilePath.toUri()}"),
86+
mapOf(ZIP_PROPERTY_POSIX to destDir.hasPosixFilePermissions())
87+
).use { zipfs ->
88+
Files.walk(zipfs.getPath("/")).use { paths ->
89+
paths
90+
.filter { !it.isDirectory() }
91+
.forEach { zipEntry ->
92+
val destPath = Paths.get(destDir.toString(), zipEntry.toString())
93+
destPath.createParentDirectories()
94+
Files.copy(zipEntry, destPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES)
9095
}
91-
}.toList()
96+
}
9297
}
9398
} catch (e: Exception) {
9499
throw LspException("Failed to extract zip file: ${e.message}", LspException.ErrorCode.UNZIP_FAILED, cause = e)

plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/LspSettings.kt

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import com.intellij.openapi.components.Service
1010
import com.intellij.openapi.components.State
1111
import com.intellij.openapi.components.Storage
1212
import com.intellij.openapi.components.service
13-
import com.intellij.util.xmlb.annotations.Property
13+
import com.intellij.util.text.nullize
1414

1515
@Service
1616
@State(name = "lspSettings", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)])
@@ -26,11 +26,7 @@ class LspSettings : PersistentStateComponent<LspConfiguration> {
2626
fun getArtifactPath() = state.artifactPath
2727

2828
fun setArtifactPath(artifactPath: String?) {
29-
if (artifactPath == null) {
30-
state.artifactPath = ""
31-
} else {
32-
state.artifactPath = artifactPath
33-
}
29+
state.artifactPath = artifactPath.nullize(nullizeSpaces = true)
3430
}
3531

3632
companion object {
@@ -39,6 +35,5 @@ class LspSettings : PersistentStateComponent<LspConfiguration> {
3935
}
4036

4137
class LspConfiguration : BaseState() {
42-
@get:Property
43-
var artifactPath: String = ""
38+
var artifactPath by string()
4439
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
5+
6+
import com.intellij.testFramework.utils.io.createDirectory
7+
import org.assertj.core.api.Assertions.assertThat
8+
import org.junit.jupiter.api.Assumptions.assumeTrue
9+
import org.junit.jupiter.api.Test
10+
import org.junit.jupiter.api.io.TempDir
11+
import software.aws.toolkits.core.utils.ZIP_PROPERTY_POSIX
12+
import software.aws.toolkits.core.utils.hasPosixFilePermissions
13+
import software.aws.toolkits.core.utils.putNextEntry
14+
import software.aws.toolkits.core.utils.test.assertPosixPermissions
15+
import software.aws.toolkits.core.utils.writeText
16+
import software.aws.toolkits.jetbrains.utils.satisfiesKt
17+
import java.nio.file.FileSystems
18+
import java.nio.file.Files
19+
import java.nio.file.Path
20+
import java.nio.file.StandardCopyOption
21+
import java.nio.file.attribute.PosixFilePermissions
22+
import java.util.zip.ZipOutputStream
23+
import kotlin.io.path.isRegularFile
24+
import kotlin.io.path.setPosixFilePermissions
25+
26+
class LspUtilsTest {
27+
@Test
28+
fun `extractZipFile works`(@TempDir tempDir: Path) {
29+
val source = tempDir.resolve("source").also { it.createDirectory() }
30+
val target = tempDir.resolve("target").also { it.createDirectory() }
31+
32+
source.resolve("file1").writeText("contents1")
33+
source.resolve("file2").writeText("contents2")
34+
source.resolve("file3").writeText("contents3")
35+
36+
val sourceZip = tempDir.resolve("source.zip")
37+
ZipOutputStream(Files.newOutputStream(sourceZip)).use { zip ->
38+
Files.walk(source).use { paths ->
39+
paths
40+
.filter { it.isRegularFile() }
41+
.forEach {
42+
zip.putNextEntry(source.relativize(it).toString(), it)
43+
}
44+
val precedingSlashFile = source.resolve("file4").also { it.writeText("contents4") }
45+
zip.putNextEntry("/${source.relativize(precedingSlashFile)}", precedingSlashFile)
46+
}
47+
}
48+
49+
extractZipFile(sourceZip, target)
50+
51+
assertThat(target).satisfiesKt {
52+
val files = Files.list(it).use { stream -> stream.toList() }
53+
assertThat(files.size).isEqualTo(4)
54+
assertThat(target.resolve("file1")).hasContent("contents1")
55+
assertThat(target.resolve("file2")).hasContent("contents2")
56+
assertThat(target.resolve("file3")).hasContent("contents3")
57+
assertThat(target.resolve("file4")).hasContent("contents4")
58+
}
59+
}
60+
61+
@Test
62+
fun `extractZipFile respects posix`(@TempDir tempDir: Path) {
63+
assumeTrue(tempDir.hasPosixFilePermissions())
64+
65+
val source = tempDir.resolve("source").also { it.createDirectory() }
66+
val target = tempDir.resolve("target").also { it.createDirectory() }
67+
68+
source.resolve("regularFile").also {
69+
it.writeText("contents1")
70+
it.setPosixFilePermissions(PosixFilePermissions.fromString("rw-r--r--"))
71+
}
72+
source.resolve("executableFile").also {
73+
it.writeText("contents2")
74+
it.setPosixFilePermissions(PosixFilePermissions.fromString("rwxr-xr-x"))
75+
}
76+
77+
val sourceZip = tempDir.resolve("source.zip")
78+
FileSystems.newFileSystem(
79+
sourceZip,
80+
mapOf(
81+
"create" to true,
82+
ZIP_PROPERTY_POSIX to true,
83+
)
84+
).use { zipfs ->
85+
Files.walk(source).use { paths ->
86+
paths
87+
.filter { it.isRegularFile() }
88+
.forEach { file ->
89+
Files.copy(file, zipfs.getPath("/").resolve(source.relativize(file).toString()), StandardCopyOption.COPY_ATTRIBUTES)
90+
}
91+
}
92+
}
93+
94+
extractZipFile(sourceZip, target)
95+
96+
assertThat(target).satisfiesKt {
97+
val files = Files.list(it).use { stream -> stream.toList() }
98+
assertThat(files.size).isEqualTo(2)
99+
assertPosixPermissions(target.resolve("regularFile"), "rw-r--r--")
100+
assertPosixPermissions(target.resolve("executableFile"), "rwxr-xr-x")
101+
}
102+
}
103+
}

plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,29 +20,35 @@ class LspSettingsTest {
2020
}
2121

2222
@Test
23-
fun `artifact path is empty by default`() {
24-
assertThat(lspSettings.getArtifactPath()).isEmpty()
23+
fun `artifact path is null by default`() {
24+
assertThat(lspSettings.getArtifactPath()).isNull()
2525
}
2626

2727
@Test
2828
fun `artifact path can be set`() {
2929
lspSettings.setArtifactPath("test\\lsp.js")
30-
assertThat(lspSettings.getArtifactPath()).isNotEmpty()
31-
assertThat(lspSettings.getArtifactPath()).isEqualTo("test\\lsp.js")
30+
assertThat(lspSettings.getArtifactPath())
31+
.isEqualTo("test\\lsp.js")
3232
}
3333

3434
@Test
35-
fun `artifact path cannot be null`() {
36-
lspSettings.setArtifactPath(null)
37-
assertThat(lspSettings.getArtifactPath()).isEmpty()
35+
fun `empty artifact path is null`() {
36+
lspSettings.setArtifactPath("")
37+
assertThat(lspSettings.getArtifactPath()).isNull()
38+
}
39+
40+
@Test
41+
fun `blank artifact path is null`() {
42+
lspSettings.setArtifactPath(" ")
43+
assertThat(lspSettings.getArtifactPath()).isNull()
3844
}
3945

4046
@Test
4147
fun `serialize settings to ensure backwards compatibility`() {
4248
val element = xmlElement(
4349
"""
4450
<component name="LspSettings">
45-
</component>
51+
</component>
4652
""".trimIndent()
4753
)
4854
lspSettings.setArtifactPath("temp\\lsp.js")
@@ -51,22 +57,26 @@ class LspSettingsTest {
5157

5258
val actual = XMLOutputter().outputString(element)
5359

54-
val expected = "<component name=\"LspSettings\">\n" +
55-
"<option name=\"artifactPath\" value=\"temp\\lsp.js\" /></component>"
60+
// language=XML
61+
val expected = """
62+
<component name="LspSettings">
63+
<option name="artifactPath" value="temp\lsp.js" />
64+
</component>
65+
""".trimIndent()
5666

57-
assertThat(actual).isEqualTo(expected)
67+
assertThat(actual).isEqualToIgnoringWhitespace(expected)
5868
}
5969

6070
@Test
6171
fun `deserialize empty settings to ensure backwards compatibility`() {
6272
val element = xmlElement(
6373
"""
64-
<component name="LspSettings">
65-
</component>
66-
"""
74+
<component name="LspSettings">
75+
</component>
76+
"""
6777
)
6878
val actual = XmlSerializer.deserialize(element, LspConfiguration::class.java)
69-
assertThat(actual.artifactPath).isEmpty()
79+
assertThat(actual.artifactPath).isNull()
7080
}
7181

7282
@Test

plugins/core/core/src/software/aws/toolkits/core/utils/PathUtils.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,6 @@ private fun<T> tryOrLogShortException(log: Logger, block: () -> T) = try {
212212
log.warn { "${e::class.simpleName}: ${e.message}" }
213213
null
214214
}
215+
216+
// https://github.com/corretto/corretto-21/blob/364eb35886643e504344136075f4a2442d6c0cb0/src/jdk.zipfs/share/classes/jdk/nio/zipfs/ZipFileSystem.java#L90C33-L90C78
217+
const val ZIP_PROPERTY_POSIX = "enablePosixFileAttributes"

plugins/core/core/src/software/aws/toolkits/core/utils/ZipUtils.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
package software.aws.toolkits.core.utils
55

6-
import java.io.BufferedInputStream
76
import java.io.ByteArrayInputStream
87
import java.io.IOException
98
import java.io.InputStream
@@ -17,7 +16,7 @@ import java.util.zip.ZipOutputStream
1716
*/
1817
fun ZipOutputStream.putNextEntry(entryName: String, file: Path) {
1918
try {
20-
BufferedInputStream(Files.newInputStream(file)).use { inputStream ->
19+
Files.newInputStream(file).buffered().use { inputStream ->
2120
putNextEntry(entryName, inputStream)
2221
}
2322
} catch (e: IOException) {

plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1256,7 +1256,7 @@ ecs.service.not_found=Service {0} not found in cluster {1}
12561256
ecs.task_definition.json_schema_name=AWS ECS Task Definition
12571257
ecs.task_definitions=Task Definitions
12581258
environment.variables.dialog.title=Environment Variables
1259-
executableCommon.auto_managed=Managed by AWS Toolkit
1259+
executableCommon.auto_managed=Managed by AWS
12601260
executableCommon.auto_resolved=Auto-detected: {0}
12611261
executableCommon.cli_not_configured={0} executable not configured
12621262
executableCommon.configurable.title=External Tools

0 commit comments

Comments
 (0)