Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
3326d10
path: add typed groundwork for pretty paths
Hona Mar 18, 2026
8abf73a
path: preserve pretty paths at instance ingress
Hona Mar 18, 2026
6c21712
path: keep logical roots in resolve flows
Hona Mar 18, 2026
8bfba24
path: normalize stored backend directory state
Hona Mar 18, 2026
e17190e
app: dedupe workspaces by normalized path keys
Hona Mar 18, 2026
58dc2dd
app: keep native path separators in displays
Hona Mar 18, 2026
0e0691b
fix(path): enforce prompt and patch permissions
Hona Mar 18, 2026
5411832
fix(path): follow physical targets for tool bounds
Hona Mar 18, 2026
5e259e5
tui: format native paths consistently
Hona Mar 18, 2026
b1b6e85
fix(path): block file route symlink escapes
Hona Mar 18, 2026
1607483
fix(path): parse ACP file uris safely
Hona Mar 18, 2026
954f384
app: render native file paths from tab state
Hona Mar 18, 2026
926f661
fix(path): key LSP state by logical paths
Hona Mar 18, 2026
2de2017
app: normalize file state across slash variants
Hona Mar 18, 2026
ddda4d6
fix(path): reject invalid lexical containment
Hona Mar 18, 2026
8ad36db
app: normalize persisted workspace directory keys
Hona Mar 18, 2026
7c3a638
path: backfill authoritative database paths
Hona Mar 18, 2026
7cce42b
app: migrate persisted sidebar path state
Hona Mar 18, 2026
369a0cd
fix(path): replace raw ancestry prefix checks
Hona Mar 18, 2026
eeb72a1
app: preserve UNC share roots in directory picker
Hona Mar 18, 2026
4203eaa
path: centralize backend path semantics
Hona Mar 18, 2026
cbe1235
app: route shared path logic through common helpers
Hona Mar 18, 2026
22a9109
test(path): cover Windows permission aliases
Hona Mar 19, 2026
75873cc
path: route tool inputs through shared helpers
Hona Mar 19, 2026
5163481
app: normalize watcher invalidation paths
Hona Mar 19, 2026
21a396f
path: normalize remaining entry boundaries
Hona Mar 19, 2026
530291f
cli: preserve logical cwd when resolving --dir
Hona Mar 19, 2026
e6a4550
Merge remote-tracking branch 'upstream/dev' into feat/brand-paths-eve…
Hona Mar 19, 2026
af5539e
path: unify repo and remote path semantics
Hona Mar 19, 2026
4ad5755
wip
Hona Mar 19, 2026
4fd3802
path: strengthen typed backend path models
Hona Mar 19, 2026
965cc9c
app: migrate path state and file tab ids
Hona Mar 19, 2026
4634bd5
path: propagate strong backend path ids
Hona Mar 19, 2026
6258245
app: propagate strong path ids through state
Hona Mar 19, 2026
4e7dde3
wip
Hona Mar 19, 2026
8e658c2
path: simplify backend path consumers
Hona Mar 19, 2026
95cf041
app: simplify path normalization at runtime
Hona Mar 19, 2026
7a42930
desktop: keep platform state migrations local
Hona Mar 19, 2026
1471390
fix(path): preserve encoded and raw workspace ingress
Hona Mar 19, 2026
3a57419
path: make strong path zod schemas JSON-safe
Hona Mar 19, 2026
a772ffd
fix(tool): keep task schema JSON-compatible
Hona Mar 19, 2026
89c4747
app: preserve path values in workspace events
Hona Mar 19, 2026
09e2567
merge upstream/dev into feat/brand-paths-everywhere
Hona Mar 23, 2026
c491482
tui: fix session export typecheck
Hona Mar 23, 2026
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
61 changes: 61 additions & 0 deletions packages/app/src/components/dialog-select-directory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test"
import {
getParentPath,
joinPath,
getPathDisplay,
getPathDisplaySeparator,
getPathRoot,
getPathScope,
getPathSearchText,
trimPrettyPath,
} from "@opencode-ai/util/path"

describe("dialog select directory display", () => {
test("keeps posix paths looking posix", () => {
expect(getPathDisplay("/Users/dev/repo", "", "/Users/dev")).toBe("~/repo")
expect(getPathDisplaySeparator("~/repo", "/Users/dev")).toBe("/")
})

test("renders windows home paths with native separators", () => {
expect(getPathDisplay("C:/Users/dev/repo", "", "C:\\Users\\dev")).toBe("~\\repo")
expect(getPathDisplaySeparator("~\\repo", "C:\\Users\\dev")).toBe("\\")
})

test("renders absolute windows paths with backslashes", () => {
expect(getPathDisplay("C:/Users/dev/repo", "C:\\", "C:\\Users\\dev")).toBe("C:\\Users\\dev\\repo")
expect(getPathDisplay("//server/share/repo", "\\\\server\\", "C:\\Users\\dev")).toBe(
"\\\\server\\share\\repo",
)
})

test("preserves UNC share roots for navigation helpers", () => {
expect(getPathRoot("\\\\server\\share\\repo")).toBe("//server/share")
expect(getPathRoot("\\\\server\\share")).toBe("//server/share")
expect(getParentPath("//server/share/repo")).toBe("//server/share")
expect(getParentPath("\\\\server\\share")).toBe("//server/share")
})

test("keeps UNC scoped search rooted at the share", () => {
expect(getPathScope("\\\\server\\share", "C:/Users/dev", "C:/Users/dev")).toEqual({
directory: "\\\\server\\share",
path: "",
})
expect(getPathScope("\\\\server\\share\\repo", "C:/Users/dev", "C:/Users/dev")).toEqual({
directory: "\\\\server\\share",
path: "repo",
})
})

test("keeps pretty paths native while joining search results", () => {
expect(trimPrettyPath("C:/Users/dev/repo/")).toBe("C:\\Users\\dev\\repo")
expect(joinPath("C:\\Users\\dev", "repo/src")).toBe("C:\\Users\\dev\\repo\\src")
expect(joinPath("\\\\server\\share", "repo")).toBe("\\\\server\\share\\repo")
})

test("indexes UNC paths in slash and native forms", () => {
const search = getPathSearchText("//server/share/repo", "C:\\Users\\dev")
expect(search).toContain("//server/share/repo")
expect(search).toContain("\\\\server\\share\\repo")
expect(search).toContain("repo")
})
})
179 changes: 53 additions & 126 deletions packages/app/src/components/dialog-select-directory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,24 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import {
getDirectory,
getFilename,
joinPath,
normalizeInputPath,
getParentPath,
getPathDisplay,
getPathDisplaySeparator,
getPathRoot,
getPathScope,
getPathSearchText,
trimPrettyPath,
} from "@opencode-ai/util/path"
import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { workspacePathKey } from "@/context/file/path"
import { useLayout } from "@/context/layout"
import { useLanguage } from "@/context/language"

Expand All @@ -28,101 +41,27 @@ function cleanInput(value: string) {
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}

function normalizePath(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
return v.replace(/\/+/g, "/")
}

function normalizeDriveRoot(input: string) {
const v = normalizePath(input)
if (/^[A-Za-z]:$/.test(v)) return v + "/"
return v
}

function trimTrailing(input: string) {
const v = normalizeDriveRoot(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
return v.replace(/\/+$/, "")
}

function joinPath(base: string | undefined, rel: string) {
const b = trimTrailing(base ?? "")
const r = trimTrailing(rel).replace(/^\/+/, "")
if (!b) return r
if (!r) return b
if (b.endsWith("/")) return b + r
return b + "/" + r
}

function rootOf(input: string) {
const v = normalizeDriveRoot(input)
if (v.startsWith("//")) return "//"
if (v.startsWith("/")) return "/"
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
return ""
}

function parentOf(input: string) {
const v = trimTrailing(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v

const i = v.lastIndexOf("/")
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}

function modeOf(input: string) {
const raw = normalizeDriveRoot(input.trim())
if (!raw) return "relative" as const
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}

function tildeOf(absolute: string, home: string) {
const full = trimTrailing(absolute)
if (!home) return ""

const hn = trimTrailing(home)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return ""
}

function displayPath(path: string, input: string, home: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full, home) || full
}

function toRow(absolute: string, home: string, group: Row["group"]): Row {
const full = trimTrailing(absolute)
const tilde = tildeOf(full, home)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}

const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search, group }
const full = trimPrettyPath(absolute)
return { absolute: full, search: getPathSearchText(full, home), group }
}

function uniqueRows(rows: Row[]) {
const seen = new Set<string>()
return rows.filter((row) => {
if (seen.has(row.absolute)) return false
seen.add(row.absolute)
const key = workspacePathKey(row.absolute)
if (seen.has(key)) return false
seen.add(key)
return true
})
}

function unique(paths: string[]) {
const seen = new Set<string>()
return paths.filter((path) => {
const key = workspacePathKey(path)
if (seen.has(key)) return false
seen.add(key)
return true
})
}
Expand All @@ -135,37 +74,22 @@ function useDirectorySearch(args: {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0

const scoped = (value: string) => {
const base = args.start()
if (!base) return

const raw = normalizeDriveRoot(value)
if (!raw) return { directory: trimTrailing(base), path: "" }

const h = args.home()
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }

const root = rootOf(raw)
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
return { directory: trimTrailing(base), path: raw }
}

const dirs = async (dir: string) => {
const key = trimTrailing(dir)
const path = trimPrettyPath(dir)
const key = workspacePathKey(path)
const existing = cache.get(key)
if (existing) return existing

const request = args.sdk.client.file
.list({ directory: key, path: "" })
.list({ directory: path, path })
.then((x) => x.data ?? [])
.catch(() => [])
.then((nodes) =>
nodes
.filter((n) => n.type === "directory")
.map((n) => ({
name: n.name,
absolute: trimTrailing(normalizeDriveRoot(n.absolute)),
absolute: trimPrettyPath(n.absolute),
})),
)

Expand All @@ -184,12 +108,12 @@ function useDirectorySearch(args: {
const active = () => token === current

const value = cleanInput(filter)
const scopedInput = scoped(value)
const scopedInput = getPathScope(value, args.start(), args.home())
if (!scopedInput) return [] as string[]

const raw = normalizeDriveRoot(value)
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
const query = normalizeDriveRoot(scopedInput.path)
const raw = normalizeInputPath(value)
const isPath = raw.startsWith("~") || !!getPathRoot(raw) || /[\\/]/.test(value)
const query = normalizeInputPath(scopedInput.path)

const find = () =>
args.sdk.client.find
Expand All @@ -213,23 +137,23 @@ function useDirectorySearch(args: {
for (const part of head) {
if (!active()) return []
if (part === "..") {
paths = paths.map(parentOf)
paths = paths.map(getParentPath)
continue
}

const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
if (!active()) return []
paths = Array.from(new Set(next)).slice(0, cap)
paths = unique(next).slice(0, cap)
if (paths.length === 0) return [] as string[]
}

const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
if (!active()) return []
const deduped = Array.from(new Set(out))
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
const deduped = unique(out)
const base = raw.startsWith("~") ? scopedInput.directory : ""
const expand = !raw.endsWith("/")
if (!expand || !tail) {
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
const items = base ? unique([base, ...deduped]) : deduped
return items.slice(0, 50)
}

Expand All @@ -240,8 +164,8 @@ function useDirectorySearch(args: {

const children = await match(target, "", 30)
if (!active()) return []
const items = Array.from(new Set([...deduped, ...children]))
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
const items = unique([...deduped, ...children])
return (base ? unique([base, ...items]) : items).slice(0, 50)
}
}

Expand Down Expand Up @@ -348,24 +272,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
e.preventDefault()
e.stopPropagation()

const value = displayPath(item.absolute, filter(), home())
list?.setFilter(value.endsWith("/") ? value : value + "/")
const value = getPathDisplay(item.absolute, filter(), home())
const sep = getPathDisplaySeparator(value, home())
list?.setFilter(/[\\/]$/.test(value) ? value : value + sep)
}}
onSelect={(path) => {
if (!path) return
resolve(path.absolute)
}}
>
{(item) => {
const path = displayPath(item.absolute, filter(), home())
const path = getPathDisplay(item.absolute, filter(), home())
const dir = getDirectory(path)
const sep = getPathDisplaySeparator(path, home())
if (path === "~") {
return (
<div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-strong whitespace-nowrap">~</span>
<span class="text-text-weak whitespace-nowrap">/</span>
<span class="text-text-weak whitespace-nowrap">{sep}</span>
</div>
</div>
</div>
Expand All @@ -377,10 +304,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(path)}
{dir}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
<span class="text-text-weak whitespace-nowrap">/</span>
<span class="text-text-weak whitespace-nowrap">{sep}</span>
</div>
</div>
</div>
Expand Down
Loading
Loading