summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
authorKhang Ha (Kelvin) <[email protected]>2026-02-08 02:27:40 +0700
committerGitHub <[email protected]>2026-02-07 13:27:40 -0600
commit4efbfcd08735bece0a3e479f23296871780a01b4 (patch)
tree5492a07e56054db401dcef4d3bbc5655a631c20b /packages/app/src/components
parent9401029b1dda6f39823221f3424bbc878a00ad72 (diff)
downloadopencode-4efbfcd08735bece0a3e479f23296871780a01b4.tar.gz
opencode-4efbfcd08735bece0a3e479f23296871780a01b4.zip
fix(app): handle Windows paths in frontend file URL encoding (#12601)
Diffstat (limited to 'packages/app/src/components')
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.test.ts210
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.ts14
2 files changed, 222 insertions, 2 deletions
diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts
index b284c3884..b0fd3a050 100644
--- a/packages/app/src/components/prompt-input/build-request-parts.test.ts
+++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts
@@ -64,4 +64,214 @@ describe("buildRequestParts", () => {
expect(fooFiles).toHaveLength(2)
expect(synthetic).toHaveLength(1)
})
+
+ test("handles Windows paths correctly (simulated on macOS)", () => {
+ const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
+
+ const result = buildRequestParts({
+ prompt,
+ context: [],
+ images: [],
+ text: "@src\\foo.ts",
+ messageID: "msg_win_1",
+ sessionID: "ses_win_1",
+ sessionDirectory: "D:\\projects\\myapp", // Windows path
+ })
+
+ // Should create valid file URLs
+ const filePart = result.requestParts.find((part) => part.type === "file")
+ expect(filePart).toBeDefined()
+ if (filePart?.type === "file") {
+ // URL should be parseable
+ expect(() => new URL(filePart.url)).not.toThrow()
+ // Should not have encoded backslashes in wrong place
+ expect(filePart.url).not.toContain("%5C")
+ // Should have normalized to forward slashes
+ expect(filePart.url).toContain("/src/foo.ts")
+ }
+ })
+
+ test("handles Windows absolute path with special characters", () => {
+ const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
+
+ const result = buildRequestParts({
+ prompt,
+ context: [],
+ images: [],
+ text: "@file#name.txt",
+ messageID: "msg_win_2",
+ sessionID: "ses_win_2",
+ sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
+ })
+
+ const filePart = result.requestParts.find((part) => part.type === "file")
+ expect(filePart).toBeDefined()
+ if (filePart?.type === "file") {
+ // URL should be parseable
+ expect(() => new URL(filePart.url)).not.toThrow()
+ // Special chars should be encoded
+ expect(filePart.url).toContain("file%23name.txt")
+ // Should have Windows drive letter properly encoded
+ expect(filePart.url).toMatch(/file:\/\/\/[A-Z]%3A/)
+ }
+ })
+
+ test("handles Linux absolute paths correctly", () => {
+ const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
+
+ const result = buildRequestParts({
+ prompt,
+ context: [],
+ images: [],
+ text: "@src/app.ts",
+ messageID: "msg_linux_1",
+ sessionID: "ses_linux_1",
+ sessionDirectory: "/home/user/project",
+ })
+
+ const filePart = result.requestParts.find((part) => part.type === "file")
+ expect(filePart).toBeDefined()
+ if (filePart?.type === "file") {
+ // URL should be parseable
+ expect(() => new URL(filePart.url)).not.toThrow()
+ // Should be a normal Unix path
+ expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
+ }
+ })
+
+ test("handles macOS paths correctly", () => {
+ const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
+
+ const result = buildRequestParts({
+ prompt,
+ context: [],
+ images: [],
+ text: "@README.md",
+ messageID: "msg_mac_1",
+ sessionID: "ses_mac_1",
+ sessionDirectory: "/Users/kelvin/Projects/opencode",
+ })
+
+ const filePart = result.requestParts.find((part) => part.type === "file")
+ expect(filePart).toBeDefined()
+ if (filePart?.type === "file") {
+ // URL should be parseable
+ expect(() => new URL(filePart.url)).not.toThrow()
+ // Should be a normal Unix path
+ expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
+ }
+ })
+
+ test("handles context files with Windows paths", () => {
+ const prompt: Prompt = []
+
+ const result = buildRequestParts({
+ prompt,
+ context: [
+ { key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
+ { key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
+ ],
+ images: [],
+ text: "test",
+ messageID: "msg_win_ctx",
+ sessionID: "ses_win_ctx",
+ sessionDirectory: "D:\\workspace\\app",
+ })
+
+ const fileParts = result.requestParts.filter((part) => part.type === "file")
+ expect(fileParts).toHaveLength(2)
+
+ // All file URLs should be valid
+ fileParts.forEach((part) => {
+ if (part.type === "file") {
+ expect(() => new URL(part.url)).not.toThrow()
+ expect(part.url).not.toContain("%5C") // No encoded backslashes
+ }
+ })
+ })
+
+ test("handles absolute Windows paths (user manually specifies full path)", () => {
+ const prompt: Prompt = [
+ { type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
+ ]
+
+ const result = buildRequestParts({
+ prompt,
+ context: [],
+ images: [],
+ text: "@D:\\other\\project\\file.ts",
+ messageID: "msg_abs",
+ sessionID: "ses_abs",
+ sessionDirectory: "C:\\current\\project",
+ })
+
+ const filePart = result.requestParts.find((part) => part.type === "file")
+ expect(filePart).toBeDefined()
+ if (filePart?.type === "file") {
+ // Should handle absolute path that differs from sessionDirectory
+ expect(() => new URL(filePart.url)).not.toThrow()
+ expect(filePart.url).toContain("/D%3A/other/project/file.ts")
+ }
+ })
+
+ test("handles selection with query parameters on Windows", () => {
+ const prompt: Prompt = [
+ {
+ type: "file",
+ path: "src\\App.tsx",
+ content: "@src\\App.tsx",
+ start: 0,
+ end: 11,
+ selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
+ },
+ ]
+
+ const result = buildRequestParts({
+ prompt,
+ context: [],
+ images: [],
+ text: "@src\\App.tsx",
+ messageID: "msg_sel",
+ sessionID: "ses_sel",
+ sessionDirectory: "C:\\project",
+ })
+
+ const filePart = result.requestParts.find((part) => part.type === "file")
+ expect(filePart).toBeDefined()
+ if (filePart?.type === "file") {
+ // Should have query parameters
+ expect(filePart.url).toContain("?start=10&end=20")
+ // Should be valid URL
+ expect(() => new URL(filePart.url)).not.toThrow()
+ // Query params should parse correctly
+ const url = new URL(filePart.url)
+ expect(url.searchParams.get("start")).toBe("10")
+ expect(url.searchParams.get("end")).toBe("20")
+ }
+ })
+
+ test("handles file paths with dots and special segments on Windows", () => {
+ const prompt: Prompt = [
+ { type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
+ ]
+
+ const result = buildRequestParts({
+ prompt,
+ context: [],
+ images: [],
+ text: "@..\\..\\shared\\util.ts",
+ messageID: "msg_dots",
+ sessionID: "ses_dots",
+ sessionDirectory: "C:\\projects\\myapp\\src",
+ })
+
+ const filePart = result.requestParts.find((part) => part.type === "file")
+ expect(filePart).toBeDefined()
+ if (filePart?.type === "file") {
+ // Should be valid URL
+ expect(() => new URL(filePart.url)).not.toThrow()
+ // Should preserve .. segments (backend normalizes)
+ expect(filePart.url).toContain("/..")
+ }
+ })
})
diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts
index 7010a1fd8..11aec9631 100644
--- a/packages/app/src/components/prompt-input/build-request-parts.ts
+++ b/packages/app/src/components/prompt-input/build-request-parts.ts
@@ -30,11 +30,21 @@ type BuildRequestPartsInput = {
const absolute = (directory: string, path: string) =>
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
-const encodeFilePath = (filepath: string): string =>
- filepath
+const encodeFilePath = (filepath: string): string => {
+ // Normalize Windows paths: convert backslashes to forward slashes
+ let normalized = filepath.replace(/\\/g, "/")
+
+ // Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs)
+ if (/^[A-Za-z]:/.test(normalized)) {
+ normalized = "/" + normalized
+ }
+
+ // Encode each path segment (preserving forward slashes as path separators)
+ return normalized
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
+}
const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""