Skip to content

Commit 4efbfcd

Browse files
authored
fix(app): handle Windows paths in frontend file URL encoding (#12601)
1 parent 9401029 commit 4efbfcd

4 files changed

Lines changed: 558 additions & 4 deletions

File tree

packages/app/src/components/prompt-input/build-request-parts.test.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,214 @@ describe("buildRequestParts", () => {
6464
expect(fooFiles).toHaveLength(2)
6565
expect(synthetic).toHaveLength(1)
6666
})
67+
68+
test("handles Windows paths correctly (simulated on macOS)", () => {
69+
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
70+
71+
const result = buildRequestParts({
72+
prompt,
73+
context: [],
74+
images: [],
75+
text: "@src\\foo.ts",
76+
messageID: "msg_win_1",
77+
sessionID: "ses_win_1",
78+
sessionDirectory: "D:\\projects\\myapp", // Windows path
79+
})
80+
81+
// Should create valid file URLs
82+
const filePart = result.requestParts.find((part) => part.type === "file")
83+
expect(filePart).toBeDefined()
84+
if (filePart?.type === "file") {
85+
// URL should be parseable
86+
expect(() => new URL(filePart.url)).not.toThrow()
87+
// Should not have encoded backslashes in wrong place
88+
expect(filePart.url).not.toContain("%5C")
89+
// Should have normalized to forward slashes
90+
expect(filePart.url).toContain("/src/foo.ts")
91+
}
92+
})
93+
94+
test("handles Windows absolute path with special characters", () => {
95+
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
96+
97+
const result = buildRequestParts({
98+
prompt,
99+
context: [],
100+
images: [],
101+
text: "@file#name.txt",
102+
messageID: "msg_win_2",
103+
sessionID: "ses_win_2",
104+
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
105+
})
106+
107+
const filePart = result.requestParts.find((part) => part.type === "file")
108+
expect(filePart).toBeDefined()
109+
if (filePart?.type === "file") {
110+
// URL should be parseable
111+
expect(() => new URL(filePart.url)).not.toThrow()
112+
// Special chars should be encoded
113+
expect(filePart.url).toContain("file%23name.txt")
114+
// Should have Windows drive letter properly encoded
115+
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]%3A/)
116+
}
117+
})
118+
119+
test("handles Linux absolute paths correctly", () => {
120+
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
121+
122+
const result = buildRequestParts({
123+
prompt,
124+
context: [],
125+
images: [],
126+
text: "@src/app.ts",
127+
messageID: "msg_linux_1",
128+
sessionID: "ses_linux_1",
129+
sessionDirectory: "/home/user/project",
130+
})
131+
132+
const filePart = result.requestParts.find((part) => part.type === "file")
133+
expect(filePart).toBeDefined()
134+
if (filePart?.type === "file") {
135+
// URL should be parseable
136+
expect(() => new URL(filePart.url)).not.toThrow()
137+
// Should be a normal Unix path
138+
expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
139+
}
140+
})
141+
142+
test("handles macOS paths correctly", () => {
143+
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
144+
145+
const result = buildRequestParts({
146+
prompt,
147+
context: [],
148+
images: [],
149+
text: "@README.md",
150+
messageID: "msg_mac_1",
151+
sessionID: "ses_mac_1",
152+
sessionDirectory: "/Users/kelvin/Projects/opencode",
153+
})
154+
155+
const filePart = result.requestParts.find((part) => part.type === "file")
156+
expect(filePart).toBeDefined()
157+
if (filePart?.type === "file") {
158+
// URL should be parseable
159+
expect(() => new URL(filePart.url)).not.toThrow()
160+
// Should be a normal Unix path
161+
expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
162+
}
163+
})
164+
165+
test("handles context files with Windows paths", () => {
166+
const prompt: Prompt = []
167+
168+
const result = buildRequestParts({
169+
prompt,
170+
context: [
171+
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
172+
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
173+
],
174+
images: [],
175+
text: "test",
176+
messageID: "msg_win_ctx",
177+
sessionID: "ses_win_ctx",
178+
sessionDirectory: "D:\\workspace\\app",
179+
})
180+
181+
const fileParts = result.requestParts.filter((part) => part.type === "file")
182+
expect(fileParts).toHaveLength(2)
183+
184+
// All file URLs should be valid
185+
fileParts.forEach((part) => {
186+
if (part.type === "file") {
187+
expect(() => new URL(part.url)).not.toThrow()
188+
expect(part.url).not.toContain("%5C") // No encoded backslashes
189+
}
190+
})
191+
})
192+
193+
test("handles absolute Windows paths (user manually specifies full path)", () => {
194+
const prompt: Prompt = [
195+
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
196+
]
197+
198+
const result = buildRequestParts({
199+
prompt,
200+
context: [],
201+
images: [],
202+
text: "@D:\\other\\project\\file.ts",
203+
messageID: "msg_abs",
204+
sessionID: "ses_abs",
205+
sessionDirectory: "C:\\current\\project",
206+
})
207+
208+
const filePart = result.requestParts.find((part) => part.type === "file")
209+
expect(filePart).toBeDefined()
210+
if (filePart?.type === "file") {
211+
// Should handle absolute path that differs from sessionDirectory
212+
expect(() => new URL(filePart.url)).not.toThrow()
213+
expect(filePart.url).toContain("/D%3A/other/project/file.ts")
214+
}
215+
})
216+
217+
test("handles selection with query parameters on Windows", () => {
218+
const prompt: Prompt = [
219+
{
220+
type: "file",
221+
path: "src\\App.tsx",
222+
content: "@src\\App.tsx",
223+
start: 0,
224+
end: 11,
225+
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
226+
},
227+
]
228+
229+
const result = buildRequestParts({
230+
prompt,
231+
context: [],
232+
images: [],
233+
text: "@src\\App.tsx",
234+
messageID: "msg_sel",
235+
sessionID: "ses_sel",
236+
sessionDirectory: "C:\\project",
237+
})
238+
239+
const filePart = result.requestParts.find((part) => part.type === "file")
240+
expect(filePart).toBeDefined()
241+
if (filePart?.type === "file") {
242+
// Should have query parameters
243+
expect(filePart.url).toContain("?start=10&end=20")
244+
// Should be valid URL
245+
expect(() => new URL(filePart.url)).not.toThrow()
246+
// Query params should parse correctly
247+
const url = new URL(filePart.url)
248+
expect(url.searchParams.get("start")).toBe("10")
249+
expect(url.searchParams.get("end")).toBe("20")
250+
}
251+
})
252+
253+
test("handles file paths with dots and special segments on Windows", () => {
254+
const prompt: Prompt = [
255+
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
256+
]
257+
258+
const result = buildRequestParts({
259+
prompt,
260+
context: [],
261+
images: [],
262+
text: "@..\\..\\shared\\util.ts",
263+
messageID: "msg_dots",
264+
sessionID: "ses_dots",
265+
sessionDirectory: "C:\\projects\\myapp\\src",
266+
})
267+
268+
const filePart = result.requestParts.find((part) => part.type === "file")
269+
expect(filePart).toBeDefined()
270+
if (filePart?.type === "file") {
271+
// Should be valid URL
272+
expect(() => new URL(filePart.url)).not.toThrow()
273+
// Should preserve .. segments (backend normalizes)
274+
expect(filePart.url).toContain("/..")
275+
}
276+
})
67277
})

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,21 @@ type BuildRequestPartsInput = {
3030
const absolute = (directory: string, path: string) =>
3131
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
3232

33-
const encodeFilePath = (filepath: string): string =>
34-
filepath
33+
const encodeFilePath = (filepath: string): string => {
34+
// Normalize Windows paths: convert backslashes to forward slashes
35+
let normalized = filepath.replace(/\\/g, "/")
36+
37+
// Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs)
38+
if (/^[A-Za-z]:/.test(normalized)) {
39+
normalized = "/" + normalized
40+
}
41+
42+
// Encode each path segment (preserving forward slashes as path separators)
43+
return normalized
3544
.split("/")
3645
.map((segment) => encodeURIComponent(segment))
3746
.join("/")
47+
}
3848

3949
const fileQuery = (selection: FileSelection | undefined) =>
4050
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""

0 commit comments

Comments
 (0)