Skip to content

Commit 6c5040e

Browse files
authored
Merge pull request #1 from jianlins/fix/windows-mapped-drive-paths
fix(windows): handle mapped network drives in path resolution and ses…
2 parents b2baddc + ff0bcd2 commit 6c5040e

8 files changed

Lines changed: 139 additions & 14 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: Build Windows Binary
2+
3+
on:
4+
workflow_dispatch:
5+
6+
jobs:
7+
build:
8+
runs-on: ubuntu-latest
9+
steps:
10+
- uses: actions/checkout@v4
11+
12+
- uses: oven-sh/setup-bun@v2
13+
with:
14+
bun-version-file: package.json
15+
16+
- name: Install dependencies
17+
run: bun install
18+
19+
- name: Install cross-platform native modules
20+
run: |
21+
cd packages/opencode
22+
bun install --os="*" --cpu="*" @opentui/core@$(bun -e "console.log(require('./package.json').dependencies['@opentui/core'])")
23+
bun install --os="*" --cpu="*" @parcel/watcher@$(bun -e "console.log(require('./package.json').dependencies['@parcel/watcher'])")
24+
25+
- name: Build all targets
26+
run: |
27+
cd packages/opencode
28+
bun run script/build.ts --skip-install
29+
env:
30+
OPENCODE_VERSION: "0.0.0-local"
31+
32+
- name: Upload Windows x64
33+
uses: actions/upload-artifact@v4
34+
with:
35+
name: opencode-windows-x64
36+
path: packages/opencode/dist/opencode-windows-x64/
37+
38+
- name: Upload Windows x64-baseline
39+
uses: actions/upload-artifact@v4
40+
with:
41+
name: opencode-windows-x64-baseline
42+
path: packages/opencode/dist/opencode-windows-x64-baseline/
43+
44+
- name: Upload Windows arm64
45+
uses: actions/upload-artifact@v4
46+
with:
47+
name: opencode-windows-arm64
48+
path: packages/opencode/dist/opencode-windows-arm64/

packages/app/src/context/global-sync/bootstrap.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,19 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
140140
}, {})
141141
}
142142

143+
function normalizePathForCompare(p: string): string {
144+
let v = p.replace(/\\/g, "/").replace(/\/+$/, "")
145+
if ((v.length >= 2 && v[1] === ":") || v.startsWith("//")) v = v.toLowerCase()
146+
return v
147+
}
148+
143149
function projectID(directory: string, projects: Project[]) {
144-
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
150+
const key = normalizePathForCompare(directory)
151+
return projects.find(
152+
(project) =>
153+
normalizePathForCompare(project.worktree) === key ||
154+
project.sandboxes?.some((s) => normalizePathForCompare(s) === key),
155+
)?.id
145156
}
146157

147158
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {

packages/core/src/filesystem.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,21 @@ export namespace AppFileSystem {
194194
return lookup(p) || "application/octet-stream"
195195
}
196196

197+
function isDriveLetterPath(p: string): boolean {
198+
return p.length >= 2 && /^[A-Za-z]:/.test(p)
199+
}
200+
201+
function isUNCPath(p: string): boolean {
202+
return p.startsWith("\\\\") || p.startsWith("//")
203+
}
204+
197205
export function normalizePath(p: string): string {
198206
if (process.platform !== "win32") return p
199207
const resolved = pathResolve(windowsPath(p))
200208
try {
201-
return realpathSync.native(resolved)
209+
const real = realpathSync.native(resolved)
210+
if (isDriveLetterPath(resolved) && isUNCPath(real)) return resolved
211+
return real
202212
} catch {
203213
return resolved
204214
}
@@ -216,9 +226,11 @@ export namespace AppFileSystem {
216226
export function resolve(p: string): string {
217227
const resolved = pathResolve(windowsPath(p))
218228
try {
219-
return normalizePath(realpathSync(resolved))
229+
const real = realpathSync(resolved)
230+
if (process.platform === "win32" && isDriveLetterPath(resolved) && isUNCPath(real)) return normalizePath(resolved)
231+
return normalizePath(real)
220232
} catch (e: any) {
221-
if (e?.code === "ENOENT") return normalizePath(resolved)
233+
if (process.platform === "win32" || e?.code === "ENOENT") return normalizePath(resolved)
222234
throw e
223235
}
224236
}

packages/opencode/src/project/project.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import z from "zod"
2-
import { and } from "drizzle-orm"
2+
import { and, or } from "drizzle-orm"
33
import { Database } from "@/storage/db"
44
import { eq } from "drizzle-orm"
55
import { ProjectTable } from "./project.sql"
@@ -21,6 +21,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
2121
import { zod } from "@/util/effect-zod"
2222
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
2323
import { serviceUse } from "@/effect/service-use"
24+
import { realpathSync } from "fs"
2425

2526
const log = Log.create({ service: "project" })
2627

@@ -344,11 +345,22 @@ export const layer: Layer.Layer<
344345
)
345346

346347
if (data.id !== ProjectID.global) {
348+
const dirs = [data.worktree]
349+
if (process.platform === "win32") {
350+
try {
351+
const real = realpathSync(data.worktree)
352+
if (real !== data.worktree) dirs.push(real)
353+
} catch {}
354+
}
355+
const dirCondition =
356+
dirs.length > 1
357+
? or(...dirs.map((d) => eq(SessionTable.directory, d)))!
358+
: eq(SessionTable.directory, data.worktree)
347359
yield* db((d) =>
348360
d
349361
.update(SessionTable)
350362
.set({ project_id: data.id })
351-
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
363+
.where(and(eq(SessionTable.project_id, ProjectID.global), dirCondition))
352364
.run(),
353365
)
354366
}

packages/opencode/src/session/session.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Slug } from "@opencode-ai/core/util/slug"
2+
import { realpathSync } from "fs"
23
import path from "path"
34
import { BusEvent } from "@/bus/bus-event"
45
import { Bus } from "@/bus"
@@ -55,6 +56,17 @@ export function isDefaultTitle(title: string) {
5556
).test(title)
5657
}
5758

59+
function directoryMatchCondition(directory: string): SQL {
60+
if (process.platform !== "win32") return eq(SessionTable.directory, directory)
61+
const alternatives = [directory]
62+
try {
63+
const real = realpathSync(directory)
64+
if (real !== directory && !alternatives.includes(real)) alternatives.push(real)
65+
} catch {}
66+
if (alternatives.length === 1) return eq(SessionTable.directory, directory)
67+
return or(...alternatives.map((d) => eq(SessionTable.directory, d)))!
68+
}
69+
5870
type SessionRow = typeof SessionTable.$inferSelect
5971

6072
export function fromRow(row: SessionRow): Info {
@@ -829,13 +841,13 @@ function* listByProject(
829841

830842
conditions.push(
831843
input.directory
832-
? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)!
844+
? or(...conds, and(isNull(SessionTable.path), directoryMatchCondition(input.directory))!)!
833845
: or(...conds)!,
834846
)
835847
}
836848
} else if (input.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
837849
if (input.directory) {
838-
conditions.push(eq(SessionTable.directory, input.directory))
850+
conditions.push(directoryMatchCondition(input.directory))
839851
}
840852
}
841853
if (input.roots) {
@@ -876,7 +888,7 @@ export function* listGlobal(input?: {
876888
const conditions: SQL[] = []
877889

878890
if (input?.directory) {
879-
conditions.push(eq(SessionTable.directory, input.directory))
891+
conditions.push(directoryMatchCondition(input.directory))
880892
}
881893
if (input?.roots) {
882894
conditions.push(isNull(SessionTable.parent_id))

packages/opencode/src/util/filesystem.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,22 @@ export async function mimeType(p: string): Promise<string> {
110110
* This is needed because Windows paths are case-insensitive but LSP servers
111111
* may return paths with different casing than what we send them.
112112
*/
113+
114+
function isDriveLetterPath(p: string): boolean {
115+
return p.length >= 2 && /^[A-Za-z]:/.test(p)
116+
}
117+
118+
function isUNCPath(p: string): boolean {
119+
return p.startsWith("\\\\") || p.startsWith("//")
120+
}
121+
113122
export function normalizePath(p: string): string {
114123
if (process.platform !== "win32") return p
115124
const resolved = win32.normalize(win32.resolve(windowsPath(p)))
116125
try {
117-
return realpathSync.native(resolved)
126+
const real = realpathSync.native(resolved)
127+
if (isDriveLetterPath(resolved) && isUNCPath(real)) return resolved
128+
return real
118129
} catch {
119130
return resolved
120131
}
@@ -135,9 +146,11 @@ export function normalizePathPattern(p: string): string {
135146
export function resolve(p: string): string {
136147
const resolved = pathResolve(windowsPath(p))
137148
try {
138-
return normalizePath(realpathSync(resolved))
149+
const real = realpathSync(resolved)
150+
if (process.platform === "win32" && isDriveLetterPath(resolved) && isUNCPath(real)) return normalizePath(resolved)
151+
return normalizePath(real)
139152
} catch (e) {
140-
if (isEnoent(e)) return normalizePath(resolved)
153+
if (process.platform === "win32" || isEnoent(e)) return normalizePath(resolved)
141154
throw e
142155
}
143156
}

packages/opencode/src/v2/session.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { SessionID } from "@/session/schema"
33
import { WorkspaceID } from "@/control-plane/schema"
44
import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db"
55
import * as Database from "@/storage/db"
6+
import { realpathSync } from "fs"
67
import { Context, DateTime, Effect, Layer, Option, Schema } from "effect"
78
import { SessionMessage } from "./session-message"
89
import type { Prompt } from "./session-prompt"
@@ -20,6 +21,17 @@ export type Delivery = Schema.Schema.Type<typeof Delivery>
2021

2122
export const DefaultDelivery = "immediate" satisfies Delivery
2223

24+
function directoryMatchCondition(directory: string): SQL {
25+
if (process.platform !== "win32") return eq(SessionTable.directory, directory)
26+
const alternatives = [directory]
27+
try {
28+
const real = realpathSync(directory)
29+
if (real !== directory && !alternatives.includes(real)) alternatives.push(real)
30+
} catch {}
31+
if (alternatives.length === 1) return eq(SessionTable.directory, directory)
32+
return or(...alternatives.map((d) => eq(SessionTable.directory, d)))!
33+
}
34+
2335
export class Info extends Schema.Class<Info>("Session.Info")({
2436
id: SessionID,
2537
parentID: optionalOmitUndefined(SessionID),
@@ -158,7 +170,7 @@ export const layer = Layer.effect(
158170
if (direction === "previous" && order === "asc") order = "desc"
159171
if (direction === "previous" && order === "desc") order = "asc"
160172
const conditions: SQL[] = []
161-
if (input.directory) conditions.push(eq(SessionTable.directory, input.directory))
173+
if (input.directory) conditions.push(directoryMatchCondition(input.directory))
162174
if (input.path)
163175
conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!)
164176
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))

packages/opencode/test/util/filesystem.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,12 @@ describe("filesystem", () => {
614614
const b = path.join(tmp.path, "b")
615615
await fs.symlink(b, a)
616616
await fs.symlink(a, b)
617-
expect(() => Filesystem.resolve(a)).toThrow()
617+
if (process.platform === "win32") {
618+
const result = Filesystem.resolve(a)
619+
expect(typeof result).toBe("string")
620+
} else {
621+
expect(() => Filesystem.resolve(a)).toThrow()
622+
}
618623
})
619624

620625
// Windows: chmod(0o000) is a no-op, so EACCES cannot be triggered

0 commit comments

Comments
 (0)