Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/app/src/components/file-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}` : ""

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 17 additions & 2 deletions packages/app/src/context/file/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) => {
Expand Down
10 changes: 6 additions & 4 deletions packages/opencode/src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -986,7 +987,7 @@ export namespace ACP {
type: "image",
mimeType: effectiveMime,
data: base64Data,
uri: `file://${filename}`,
uri: pathToFileURL(filename).href,
},
},
})
Expand All @@ -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({
Expand Down Expand Up @@ -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",
}
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -310,7 +311,7 @@ export const RunCommand = cmd({

files.push({
type: "file",
url: `file://${resolvedPath}`,
url: pathToFileURL(resolvedPath).href,
filename: path.basename(resolvedPath),
mime,
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/lsp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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", {
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
})
Expand All @@ -219,7 +219,7 @@ export namespace SessionPrompt {

parts.push({
type: "file",
url: `file://${filepath}`,
url: pathToFileURL(filepath).href,
filename: name,
mime: "text/plain",
})
Expand Down
56 changes: 56 additions & 0 deletions packages/opencode/test/session/prompt-special-chars.test.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
})
})
5 changes: 3 additions & 2 deletions packages/sdk/js/example/example.ts
Original file line number Diff line number Diff line change
@@ -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 })
Expand All @@ -17,7 +18,7 @@ for await (const file of input) {
{
type: "file",
mime: "text/plain",
url: `file://${file}`,
url: pathToFileURL(file).href,
},
{
type: "text",
Expand All @@ -41,7 +42,7 @@ await Promise.all(
{
type: "file",
mime: "text/plain",
url: `file://${file}`,
url: pathToFileURL(file).href,
},
{
type: "text",
Expand Down
3 changes: 2 additions & 1 deletion script/duplicate-pr.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -49,7 +50,7 @@ Examples:
}
parts.push({
type: "file",
url: `file://${resolved}`,
url: pathToFileURL(resolved).href,
filename: path.basename(resolved),
mime: "text/plain",
})
Expand Down
Loading