From cb2dda54adde156f3b3404950d4851b7dc473d73 Mon Sep 17 00:00:00 2001 From: Tony Coder <407243179@qq.com> Date: Mon, 17 Aug 2026 09:05:10 +0000 Subject: [PATCH] fix(intellij): decode percent-encoded file URIs before touching the filesystem Project paths containing spaces reach the plugin as encoded URIs (e.g. file:///C:/Unity%20Projects/My%20Project). VfsUtilCore.urlToPath and VirtualFileManager.findFileByUrl do not percent-decode, so writes created literal "%20" directories and lookups failed to match existing files. Resolve URIs through UriUtils, which decodes the path, and make parseUri decode authority-form Windows URIs as well. Fixes #13134 Co-authored-by: Tony Coder <407243179@qq.com> --- .../continue/UriUtils.kt | 27 ++++++++++----- .../continue/file/FileUtils.kt | 33 +++++++++++-------- .../unit/FileUtilsTest.kt | 16 +++++++++ .../unit/UriUtilsTest.kt | 7 ++++ 4 files changed, 60 insertions(+), 23 deletions(-) diff --git a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/UriUtils.kt b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/UriUtils.kt index d2030280f28..7af5e0e069e 100644 --- a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/UriUtils.kt +++ b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/UriUtils.kt @@ -21,22 +21,21 @@ object UriUtils { */ fun parseUri(uri: String): URI { // Remove query parameters if present - val uriStr = uri.substringBefore("?") + val noParams = uri.substringBefore("?") - // Handle Windows file paths with authority component - if (uriStr.startsWith("file://") && !uriStr.startsWith("file:///")) { - val path = uriStr.substringAfter("file://") - return URI("file", "", "/$path", null) - } + // Normalize Windows file paths with authority component, so that the path + // is percent-decoded by URI just like for authority-less file URIs + val uriStr = if (noParams.startsWith("file://") && !noParams.startsWith("file:///")) + "file:///" + noParams.removePrefix("file://") + else + noParams return try { URI(uriStr) } catch (e: Exception) { // Handle unencoded file URIs (e.g. spaces in path from VirtualFile.toUriOrNull()) if (uriStr.startsWith("file:///")) { - val path = uriStr.removePrefix("file://") - val file = File(path) - file.toURI() + URI("file", "", uriStr.removePrefix("file://"), null) } else { throw Exception("Invalid URI: $uri ${e.message}") } @@ -54,4 +53,14 @@ object UriUtils { val parsedUri = parseUri(uri) return File(parsedUri) } + + /** + * Converts a URI string to a filesystem path with forward slashes, the + * format expected by the IntelliJ virtual file system. + * + * @param uri The URI string to convert to a file path + * @return A percent-decoded path, e.g. "C:/My Project/file.txt" + */ + fun uriToPath(uri: String): String = + uriToFile(uri).path.replace('\\', '/') } \ No newline at end of file diff --git a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/file/FileUtils.kt b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/file/FileUtils.kt index 6aac270cdfb..c3223fe34f7 100644 --- a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/file/FileUtils.kt +++ b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/file/FileUtils.kt @@ -2,6 +2,7 @@ package com.github.continuedev.continueintellijextension.`continue`.file import com.github.continuedev.continueintellijextension.FileStats import com.github.continuedev.continueintellijextension.FileType +import com.github.continuedev.continueintellijextension.`continue`.UriUtils import com.intellij.openapi.application.runReadAction import com.intellij.openapi.application.runWriteAction import com.intellij.openapi.diagnostic.Logger @@ -23,7 +24,8 @@ class FileUtils( findFile(fileUri) != null fun writeFile(fileUri: String, content: String) { - val path = VfsUtilCore.urlToPath(fileUri) + val path = toLocalPath(fileUri) + ?: return val pathDirectory = VfsUtil.getParentDir(path) ?: return LOG.warn("Parent directory is null for $path") val vfsDirectory = VfsUtil.createDirectories(pathDirectory) @@ -91,12 +93,25 @@ class FileUtils( }.toMap() private fun findFile(fileUri: String): VirtualFile? { - val noParams = fileUri.substringBefore("?") - val normalizedAuthority = normalizeWindowsAuthority(noParams) + val path = toLocalPath(fileUri) + ?: return null return VirtualFileManager.getInstance() - .refreshAndFindFileByUrl(normalizedAuthority) + .refreshAndFindFileByUrl(VfsUtilCore.pathToUrl(path)) } + /** + * Resolves a file URI to a filesystem path, percent-decoding it so that + * paths containing spaces (e.g. "C:/My Project") are not mistaken for + * literal "%20" segments. + */ + private fun toLocalPath(fileUri: String): String? = + try { + UriUtils.uriToPath(fileUri) + } catch (e: Exception) { + LOG.warn("Could not resolve path for $fileUri", e) + null + } + private fun readDocument(file: VirtualFile, maxLength: Int): String? { val document = FileDocumentManager.getInstance().getDocument(file) ?: return null @@ -108,16 +123,6 @@ class FileUtils( text.replace("\r\n", "\n") .replace("\r", "\n") - private fun normalizeWindowsAuthority(fileUri: String): String { - val authorityPrefix = "file://" - val noAuthorityPrefix = "file:///" - if (fileUri.startsWith(authorityPrefix) && !fileUri.startsWith(noAuthorityPrefix)) { - val path = fileUri.substringAfter(authorityPrefix) - return "$noAuthorityPrefix$path" - } - return fileUri - } - private companion object { private val LOG = Logger.getInstance(FileUtils::class.java) } diff --git a/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/FileUtilsTest.kt b/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/FileUtilsTest.kt index 338cb6fdafd..374d3b49fb0 100644 --- a/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/FileUtilsTest.kt +++ b/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/FileUtilsTest.kt @@ -57,6 +57,12 @@ class FileUtilsTest : UsefulTestCase() { assertEquals("new_text", myFixture.readTempFile("overwrite.txt")) } + // Regression test for #13134 — encoded URIs must not create %20 directories + fun `test writeFile decodes percent-encoded path`() { + fileUtils.writeFile("file://$tmp/Unity%20Projects/My%20Project/file.txt", "text") + assertEquals("text", myFixture.readTempFile("Unity Projects/My Project/file.txt")) + } + fun `test readFile`() { myFixture.createTempFile("file.txt", "text") assertEquals("text", fileUtils.readFile("file://$tmp/file.txt")) @@ -77,6 +83,16 @@ class FileUtilsTest : UsefulTestCase() { assertEmpty(fileUtils.readFile("file://missing.txt")) } + fun `test readFile decodes percent-encoded path`() { + myFixture.createTempFile("Unity Projects/My Project/file.txt", "text") + assertEquals("text", fileUtils.readFile("file://$tmp/Unity%20Projects/My%20Project/file.txt")) + } + + fun `test fileExists decodes percent-encoded path`() { + myFixture.createTempFile("Unity Projects/My Project/file.txt") + assertTrue(fileUtils.fileExists("file://$tmp/Unity%20Projects/My%20Project/file.txt")) + } + fun `test readFile normalizes line endings`() { myFixture.createTempFile("file.txt", "line\r\nline\rline\nline") val normalized = fileUtils.readFile("file://$tmp/file.txt") diff --git a/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/UriUtilsTest.kt b/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/UriUtilsTest.kt index 8295abfe607..4683295bf44 100644 --- a/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/UriUtilsTest.kt +++ b/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/UriUtilsTest.kt @@ -68,6 +68,13 @@ class UriUtilsTest : TestCase() { assertEquals(File("/path/to/[gamemode]/file.lua"), result) } + // Regression test for #13134 — Windows paths with spaces from JetBrains IDEs + fun `test Windows path with authority and encoded spaces`() { + val uri = "file://C:/Unity%20Projects/My%20Project/file.txt" + val parsed = UriUtils.parseUri(uri) + assertEquals("/C:/Unity Projects/My Project/file.txt", parsed.path) + } + fun `test Windows path with square brackets`() { val uri = "file://C:/Users/user/projects/[gamemode]/file.lua" val parsed = UriUtils.parseUri(uri)