Przeglądaj źródła

fix: properly encode file URLs with special characters (#12424)

Khang Ha (Kelvin) 1 tydzień temu
rodzic
commit
fde0b39b7c

+ 9 - 1
packages/app/src/components/file-tree.tsx

@@ -19,6 +19,14 @@ import {
 import { Dynamic } from "solid-js/web"
 import type { FileNode } from "@opencode-ai/sdk/v2"
 
+function pathToFileUrl(filepath: string): string {
+  const encodedPath = filepath
+    .split("/")
+    .map((segment) => encodeURIComponent(segment))
+    .join("/")
+  return `file://${encodedPath}`
+}
+
 type Kind = "add" | "del" | "mix"
 
 type Filter = {
@@ -247,7 +255,7 @@ export default function FileTree(props: {
         onDragStart={(e: DragEvent) => {
           if (!draggable()) return
           e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
-          e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`)
+          e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
           if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
 
           const dragImage = document.createElement("div")

+ 8 - 2
packages/app/src/components/prompt-input/build-request-parts.ts

@@ -30,6 +30,12 @@ type BuildRequestPartsInput = {
 const absolute = (directory: string, path: string) =>
   path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
 
+const encodeFilePath = (filepath: string): string =>
+  filepath
+    .split("/")
+    .map((segment) => encodeURIComponent(segment))
+    .join("/")
+
 const fileQuery = (selection: FileSelection | undefined) =>
   selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
 
@@ -99,7 +105,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
       id: Identifier.ascending("part"),
       type: "file",
       mime: "text/plain",
-      url: `file://${path}${fileQuery(attachment.selection)}`,
+      url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
       filename: getFilename(attachment.path),
       source: {
         type: "file",
@@ -129,7 +135,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
   const used = new Set(files.map((part) => part.url))
   const context = input.context.flatMap((item) => {
     const path = absolute(input.sessionDirectory, item.path)
-    const url = `file://${path}${fileQuery(item.selection)}`
+    const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
     const comment = item.comment?.trim()
     if (!comment && used.has(url)) return []
     used.add(url)

+ 17 - 2
packages/app/src/context/file/path.ts

@@ -72,12 +72,27 @@ export function unquoteGitPath(input: string) {
   return new TextDecoder().decode(new Uint8Array(bytes))
 }
 
+export function decodeFilePath(input: string) {
+  try {
+    return decodeURIComponent(input)
+  } catch {
+    return input
+  }
+}
+
+export function encodeFilePath(filepath: string): string {
+  return filepath
+    .split("/")
+    .map((segment) => encodeURIComponent(segment))
+    .join("/")
+}
+
 export function createPathHelpers(scope: () => string) {
   const normalize = (input: string) => {
     const root = scope()
     const prefix = root.endsWith("/") ? root : root + "/"
 
-    let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
+    let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
 
     if (path.startsWith(prefix)) {
       path = path.slice(prefix.length)
@@ -100,7 +115,7 @@ export function createPathHelpers(scope: () => string) {
 
   const tab = (input: string) => {
     const path = normalize(input)
-    return `file://${path}`
+    return `file://${encodeFilePath(path)}`
   }
 
   const pathFromTab = (tabValue: string) => {

+ 6 - 4
packages/opencode/src/acp/agent.ts

@@ -29,6 +29,7 @@ import {
 } from "@agentclientprotocol/sdk"
 
 import { Log } from "../util/log"
+import { pathToFileURL } from "bun"
 import { ACPSessionManager } from "./session"
 import type { ACPConfig } from "./types"
 import { Provider } from "../provider/provider"
@@ -986,7 +987,7 @@ export namespace ACP {
                       type: "image",
                       mimeType: effectiveMime,
                       data: base64Data,
-                      uri: `file://${filename}`,
+                      uri: pathToFileURL(filename).href,
                     },
                   },
                 })
@@ -996,13 +997,14 @@ export namespace ACP {
             } else {
               // Non-image: text types get decoded, binary types stay as blob
               const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
+              const fileUri = pathToFileURL(filename).href
               const resource = isText
                 ? {
-                    uri: `file://${filename}`,
+                    uri: fileUri,
                     mimeType: effectiveMime,
                     text: Buffer.from(base64Data, "base64").toString("utf-8"),
                   }
-                : { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data }
+                : { uri: fileUri, mimeType: effectiveMime, blob: base64Data }
 
               await this.connection
                 .sessionUpdate({
@@ -1544,7 +1546,7 @@ export namespace ACP {
           const name = path.split("/").pop() || path
           return {
             type: "file",
-            url: `file://${path}`,
+            url: pathToFileURL(path).href,
             filename: name,
             mime: "text/plain",
           }

+ 2 - 1
packages/opencode/src/cli/cmd/run.ts

@@ -1,5 +1,6 @@
 import type { Argv } from "yargs"
 import path from "path"
+import { pathToFileURL } from "bun"
 import { UI } from "../ui"
 import { cmd } from "./cmd"
 import { Flag } from "../../flag/flag"
@@ -314,7 +315,7 @@ export const RunCommand = cmd({
 
         files.push({
           type: "file",
-          url: `file://${resolvedPath}`,
+          url: pathToFileURL(resolvedPath).href,
           filename: path.basename(resolvedPath),
           mime,
         })

+ 2 - 1
packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx

@@ -1,4 +1,5 @@
 import { TextAttributes } from "@opentui/core"
+import { fileURLToPath } from "bun"
 import { useTheme } from "../context/theme"
 import { useDialog } from "@tui/ui/dialog"
 import { useSync } from "@tui/context/sync"
@@ -19,7 +20,7 @@ export function DialogStatus() {
     const list = sync.data.config.plugin ?? []
     const result = list.map((value) => {
       if (value.startsWith("file://")) {
-        const path = value.substring("file://".length)
+        const path = fileURLToPath(value)
         const parts = path.split("/")
         const filename = parts.pop() || path
         if (!filename.includes(".")) return { name: filename }

+ 4 - 3
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -1,4 +1,5 @@
 import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
+import { pathToFileURL } from "bun"
 import fuzzysort from "fuzzysort"
 import { firstBy } from "remeda"
 import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
@@ -246,17 +247,17 @@ export function Autocomplete(props: {
         const width = props.anchor().width - 4
         options.push(
           ...sortedFiles.map((item): AutocompleteOption => {
-            let url = `file://${process.cwd()}/${item}`
+            const fullPath = `${process.cwd()}/${item}`
+            const urlObj = pathToFileURL(fullPath)
             let filename = item
             if (lineRange && !item.endsWith("/")) {
               filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
-              const urlObj = new URL(url)
               urlObj.searchParams.set("start", String(lineRange.startLine))
               if (lineRange.endLine !== undefined) {
                 urlObj.searchParams.set("end", String(lineRange.endLine))
               }
-              url = urlObj.toString()
             }
+            const url = urlObj.href
 
             const isDir = item.endsWith("/")
             return {

+ 2 - 2
packages/opencode/src/lsp/index.ts

@@ -3,7 +3,7 @@ import { Bus } from "@/bus"
 import { Log } from "../util/log"
 import { LSPClient } from "./client"
 import path from "path"
-import { pathToFileURL } from "url"
+import { pathToFileURL, fileURLToPath } from "url"
 import { LSPServer } from "./server"
 import z from "zod"
 import { Config } from "../config/config"
@@ -369,7 +369,7 @@ export namespace LSP {
   }
 
   export async function documentSymbol(uri: string) {
-    const file = new URL(uri).pathname
+    const file = fileURLToPath(uri)
     return run(file, (client) =>
       client.connection
         .sendRequest("textDocument/documentSymbol", {

+ 3 - 3
packages/opencode/src/session/prompt.ts

@@ -32,7 +32,7 @@ import { Flag } from "../flag/flag"
 import { ulid } from "ulid"
 import { spawn } from "child_process"
 import { Command } from "../command"
-import { $, fileURLToPath } from "bun"
+import { $, fileURLToPath, pathToFileURL } from "bun"
 import { ConfigMarkdown } from "../config/markdown"
 import { SessionSummary } from "./summary"
 import { NamedError } from "@opencode-ai/util/error"
@@ -210,7 +210,7 @@ export namespace SessionPrompt {
         if (stats.isDirectory()) {
           parts.push({
             type: "file",
-            url: `file://${filepath}`,
+            url: pathToFileURL(filepath).href,
             filename: name,
             mime: "application/x-directory",
           })
@@ -219,7 +219,7 @@ export namespace SessionPrompt {
 
         parts.push({
           type: "file",
-          url: `file://${filepath}`,
+          url: pathToFileURL(filepath).href,
           filename: name,
           mime: "text/plain",
         })

+ 56 - 0
packages/opencode/test/session/prompt-special-chars.test.ts

@@ -0,0 +1,56 @@
+import path from "path"
+import { describe, expect, test } from "bun:test"
+import { fileURLToPath } from "url"
+import { Instance } from "../../src/project/instance"
+import { Log } from "../../src/util/log"
+import { Session } from "../../src/session"
+import { SessionPrompt } from "../../src/session/prompt"
+import { MessageV2 } from "../../src/session/message-v2"
+import { tmpdir } from "../fixture/fixture"
+
+Log.init({ print: false })
+
+describe("session.prompt special characters", () => {
+  test("handles filenames with # character", async () => {
+    await using tmp = await tmpdir({
+      git: true,
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
+      },
+    })
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const session = await Session.create({})
+        const template = "Read @file#name.txt"
+        const parts = await SessionPrompt.resolvePromptParts(template)
+        const fileParts = parts.filter((part) => part.type === "file")
+
+        expect(fileParts.length).toBe(1)
+        expect(fileParts[0].filename).toBe("file#name.txt")
+
+        // Verify the URL is properly encoded (# should be %23)
+        expect(fileParts[0].url).toContain("%23")
+
+        // Verify the URL can be correctly converted back to a file path
+        const decodedPath = fileURLToPath(fileParts[0].url)
+        expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
+
+        const message = await SessionPrompt.prompt({
+          sessionID: session.id,
+          parts,
+          noReply: true,
+        })
+        const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
+
+        // Verify the file content was read correctly
+        const textParts = stored.parts.filter((part) => part.type === "text")
+        const hasContent = textParts.some((part) => part.text.includes("special content"))
+        expect(hasContent).toBe(true)
+
+        await Session.remove(session.id)
+      },
+    })
+  })
+})

+ 3 - 2
packages/sdk/js/example/example.ts

@@ -1,4 +1,5 @@
 import { createOpencodeClient, createOpencodeServer } from "@opencode-ai/sdk"
+import { pathToFileURL } from "bun"
 
 const server = await createOpencodeServer()
 const client = createOpencodeClient({ baseUrl: server.url })
@@ -17,7 +18,7 @@ for await (const file of input) {
           {
             type: "file",
             mime: "text/plain",
-            url: `file://${file}`,
+            url: pathToFileURL(file).href,
           },
           {
             type: "text",
@@ -41,7 +42,7 @@ await Promise.all(
           {
             type: "file",
             mime: "text/plain",
-            url: `file://${file}`,
+            url: pathToFileURL(file).href,
           },
           {
             type: "text",

+ 2 - 1
script/duplicate-pr.ts

@@ -1,6 +1,7 @@
 #!/usr/bin/env bun
 
 import path from "path"
+import { pathToFileURL } from "bun"
 import { createOpencode } from "@opencode-ai/sdk"
 import { parseArgs } from "util"
 
@@ -49,7 +50,7 @@ Examples:
       }
       parts.push({
         type: "file",
-        url: `file://${resolved}`,
+        url: pathToFileURL(resolved).href,
         filename: path.basename(resolved),
         mime: "text/plain",
       })