diff --git a/packages/app/src/components/dialog-select-directory.test.ts b/packages/app/src/components/dialog-select-directory.test.ts new file mode 100644 index 000000000000..ddb42caa6dfe --- /dev/null +++ b/packages/app/src/components/dialog-select-directory.test.ts @@ -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") + }) +}) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 91e23f8ffa5f..552cd08e31ce 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -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" @@ -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() 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() + return paths.filter((path) => { + const key = workspacePathKey(path) + if (seen.has(key)) return false + seen.add(key) return true }) } @@ -135,29 +74,14 @@ function useDirectorySearch(args: { const cache = new Map>>() 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) => @@ -165,7 +89,7 @@ function useDirectorySearch(args: { .filter((n) => n.type === "directory") .map((n) => ({ name: n.name, - absolute: trimTrailing(normalizeDriveRoot(n.absolute)), + absolute: trimPrettyPath(n.absolute), })), ) @@ -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 @@ -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) } @@ -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) } } @@ -348,8 +272,9 @@ 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 @@ -357,7 +282,9 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { }} > {(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 (
@@ -365,7 +292,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
~ - / + {sep}
@@ -377,10 +304,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
- {getDirectory(path)} + {dir} {getFilename(path)} - / + {sep}
diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index e21be77fb94d..93a4ee492bac 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -14,6 +14,7 @@ import { useGlobalSync } from "@/context/global-sync" import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { findProjectByDirectory, workspaceEqual } from "@/pages/layout/helpers" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { decode64 } from "@/utils/base64" @@ -69,10 +70,10 @@ const createCommandEntry = (option: CommandOption, category: string): Entry => ( option, }) -const createFileEntry = (path: string, category: string): Entry => ({ +const createFileEntry = (path: string, title: string, category: string): Entry => ({ id: "file:" + path, type: "file", - title: path, + title, category, path, }) @@ -137,7 +138,7 @@ function createFileEntries(props: { const tabState = createSessionTabs({ tabs: props.tabs, pathFromTab: props.file.pathFromTab, - normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab), + normalizeTab: props.file.normalizeTab, }) const recent = createMemo(() => { const all = tabState.openedTabs() @@ -152,7 +153,7 @@ function createFileEntries(props: { if (!path) continue if (seen.has(path)) continue seen.add(path) - items.push(createFileEntry(path, category)) + items.push(createFileEntry(path, props.file.display(path), category)) } return items.slice(0, ENTRY_LIMIT) @@ -165,7 +166,7 @@ function createFileEntries(props: { .filter((node) => node.type === "file") .map((node) => node.path) .sort((a, b) => a.localeCompare(b)) - return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category)) + return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, props.file.display(path), category)) }) return { recent, root } @@ -280,7 +281,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const project = createMemo(() => { const directory = projectDirectory() if (!directory) return - return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + return findProjectByDirectory(layout.projects.list(), directory) }) const workspaces = createMemo(() => { const directory = projectDirectory() @@ -288,7 +289,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil if (!current) return directory ? [directory] : [] const dirs = [current.worktree, ...(current.sandboxes ?? [])] - if (directory && !dirs.includes(directory)) return [...dirs, directory] + if (directory && !dirs.some((dir) => workspaceEqual(dir, directory))) return [...dirs, directory] return dirs }) const homedir = createMemo(() => globalSync.data.path.home) @@ -330,12 +331,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil if (filesOnly()) { const files = await file.searchFiles(query) const category = language.t("palette.group.files") - return files.map((path) => createFileEntry(path, category)) + return files.map((path) => createFileEntry(path, file.display(path), category)) } const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) const category = language.t("palette.group.files") - const entries = files.map((path) => createFileEntry(path, category)) + const entries = files.map((path) => createFileEntry(path, file.display(path), category)) return [...commandEntries.list(), ...nextSessions, ...entries] } @@ -409,9 +410,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
- {getDirectory(item.path ?? "")} + {getDirectory(item.title)} - {getFilename(item.path ?? "")} + {getFilename(item.title)}
diff --git a/packages/app/src/components/file-tree.test.ts b/packages/app/src/components/file-tree.test.ts index 29e20b4807c5..c0a7f7816a6c 100644 --- a/packages/app/src/components/file-tree.test.ts +++ b/packages/app/src/components/file-tree.test.ts @@ -1,4 +1,5 @@ import { beforeAll, describe, expect, mock, test } from "bun:test" +import { filePathKey } from "@/context/file/path" let shouldListRoot: typeof import("./file-tree").shouldListRoot let shouldListExpanded: typeof import("./file-tree").shouldListExpanded @@ -53,8 +54,8 @@ describe("file tree fetch discipline", () => { }) test("allowed auto-expand picks only collapsed dirs", () => { - const expanded = new Set() - const filter = { dirs: new Set(["src", "src/components"]) } + const expanded = new Set>() + const filter = { dirs: new Set([filePathKey("src"), filePathKey("src/components")]) } const first = dirsToExpand({ level: 0, @@ -62,7 +63,7 @@ describe("file tree fetch discipline", () => { expanded: (dir) => expanded.has(dir), }) - expect(first).toEqual(["src", "src/components"]) + expect(first.map(String)).toEqual(["src", "src/components"]) for (const dir of first) expanded.add(dir) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 930832fb6555..d29c79966719 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -1,8 +1,17 @@ import { useFile } from "@/context/file" -import { encodeFilePath } from "@/context/file/path" +import { + filePathAncestorKeys, + filePathEqual, + filePathFromKey, + filePathKey, + filePathName, + filePathParentKey, + type FilePathKey, +} from "@/context/file/path" import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" +import { encodeFilePath, getPathRoot } from "@opencode-ai/util/path" import { createEffect, createMemo, @@ -22,15 +31,18 @@ import type { FileNode } from "@opencode-ai/sdk/v2" const MAX_DEPTH = 128 -function pathToFileUrl(filepath: string): string { - return `file://${encodeFilePath(filepath)}` +function pathToFileUrl(filepath: string) { + if (!getPathRoot(filepath)) return + const path = encodeFilePath(filepath) + if (path.startsWith("//")) return `file:${path}` + return `file://${path}` } type Kind = "add" | "del" | "mix" type Filter = { - files: Set - dirs: Set + files: Set + dirs: Set } export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) { @@ -53,8 +65,8 @@ export function shouldListExpanded(input: { export function dirsToExpand(input: { level: number - filter?: { dirs: Set } - expanded: (dir: string) => boolean + filter?: { dirs: Set } + expanded: (dir: FilePathKey) => boolean }) { if (input.level !== 0) return [] if (!input.filter) return [] @@ -80,9 +92,10 @@ const kindDotColor = (kind: Kind) => { } const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: Set) => { - const kind = kinds?.get(node.path) + const key = filePathKey(node.path) + const kind = kinds?.get(key) if (!kind) return - if (!marks?.has(node.path)) return + if (!marks?.has(key)) return return kind } @@ -148,7 +161,7 @@ const FileTreeNode = ( component={local.as ?? "div"} classList={{ "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true, - "bg-surface-base-active": local.node.path === local.active, + "bg-surface-base-active": filePathEqual(local.node.path, local.active), ...(local.classList ?? {}), [local.class ?? ""]: !!local.class, [local.nodeClass ?? ""]: !!local.nodeClass, @@ -158,7 +171,8 @@ const FileTreeNode = ( onDragStart={(event: DragEvent) => { if (!local.draggable) return event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) - event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) + const url = pathToFileUrl(local.node.absolute) + if (url) event.dataTransfer?.setData("text/uri-list", url) if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" withFileDragImage(event) }} @@ -204,20 +218,16 @@ export default function FileTree(props: { onFileClick?: (file: FileNode) => void _filter?: Filter - _marks?: Set - _deeps?: Map + _marks?: Set + _deeps?: Map _kinds?: ReadonlyMap - _chain?: readonly string[] + _chain?: readonly FilePathKey[] }) { const file = useFile() const level = props.level ?? 0 const draggable = () => props.draggable ?? true - const key = (p: string) => - file - .normalize(p) - .replace(/[\\/]+$/, "") - .replaceAll("\\", "/") + const key = (p: string) => filePathKey(file.normalize(p)) const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)] const filter = createMemo(() => { @@ -226,17 +236,8 @@ export default function FileTree(props: { const allowed = props.allowed if (!allowed) return - const files = new Set(allowed) - const dirs = new Set() - - for (const item of allowed) { - const parts = item.split("/") - const parents = parts.slice(0, -1) - for (const [idx] of parents.entries()) { - const dir = parents.slice(0, idx + 1).join("/") - if (dir) dirs.add(dir) - } - } + const files = new Set(allowed.map(filePathKey)) + const dirs = new Set(allowed.flatMap((item) => filePathAncestorKeys(filePathKey(item)))) return { files, dirs } }) @@ -244,9 +245,9 @@ export default function FileTree(props: { const marks = createMemo(() => { if (props._marks) return props._marks - const out = new Set() - for (const item of props.modified ?? []) out.add(item) - for (const item of props.kinds?.keys() ?? []) out.add(item) + const out = new Set() + for (const item of props.modified ?? []) out.add(filePathKey(item)) + for (const item of props.kinds?.keys() ?? []) out.add(filePathKey(item)) if (out.size === 0) return return out }) @@ -259,12 +260,12 @@ export default function FileTree(props: { const deeps = createMemo(() => { if (props._deeps) return props._deeps - const out = new Map() + const out = new Map() const root = props.path if (!(file.tree.state(root)?.expanded ?? false)) return out - const seen = new Set() + const seen = new Set() const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = [] const push = (dir: string, lvl: number) => { @@ -292,7 +293,7 @@ export default function FileTree(props: { continue } - out.set(top.dir, top.max) + out.set(key(top.dir), top.max) stack.pop() const parent = stack[stack.length - 1] @@ -329,32 +330,22 @@ export default function FileTree(props: { const nodes = file.tree.children(props.path) const current = filter() if (!current) return nodes - - const parent = (path: string) => { - const idx = path.lastIndexOf("/") - if (idx === -1) return "" - return path.slice(0, idx) - } - - const leaf = (path: string) => { - const idx = path.lastIndexOf("/") - return idx === -1 ? path : path.slice(idx + 1) - } + const parent = key(props.path) const out = nodes.filter((node) => { - if (node.type === "file") return current.files.has(node.path) - return current.dirs.has(node.path) + if (node.type === "file") return current.files.has(filePathKey(node.path)) + return current.dirs.has(filePathKey(node.path)) }) - const seen = new Set(out.map((node) => node.path)) + const seen = new Set(out.map((node) => filePathKey(node.path))) for (const dir of current.dirs) { - if (parent(dir) !== props.path) continue + if (filePathParentKey(dir) !== parent) continue if (seen.has(dir)) continue out.push({ - name: leaf(dir), - path: dir, - absolute: dir, + name: filePathName(dir), + path: filePathFromKey(dir), + absolute: filePathFromKey(dir), type: "directory", ignored: false, }) @@ -362,12 +353,12 @@ export default function FileTree(props: { } for (const item of current.files) { - if (parent(item) !== props.path) continue + if (filePathParentKey(item) !== parent) continue if (seen.has(item)) continue out.push({ - name: leaf(item), - path: item, - absolute: item, + name: filePathName(item), + path: filePathFromKey(item), + absolute: filePathFromKey(item), type: "file", ignored: false, }) @@ -389,7 +380,7 @@ export default function FileTree(props: { {(node) => { const expanded = () => file.tree.state(node.path)?.expanded ?? false - const deep = () => deeps().get(node.path) ?? -1 + const deep = () => deeps().get(key(node.path)) ?? -1 const kind = () => visibleKind(node, kinds(), marks()) const active = () => !!kind() && !node.ignored diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f3d3e135deac..905c4ffd8b09 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -37,6 +37,7 @@ import { usePlatform } from "@/context/platform" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { promptEnabled, promptProbe } from "@/testing/prompt" +import { filePathEqual, filePathKey } from "@/context/file/path" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments } from "./prompt-input/attachments" import { ACCEPTED_FILE_TYPES } from "./prompt-input/files" @@ -167,7 +168,7 @@ export const PromptInput: Component = (props) => { const activeFileTab = createSessionTabs({ tabs, pathFromTab: files.pathFromTab, - normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), + normalizeTab: files.normalizeTab, }).activeFileTab const commentInReview = (path: string) => { @@ -176,7 +177,7 @@ export const PromptInput: Component = (props) => { const diffs = sync.data.session_diff[sessionID] if (!diffs) return false - return diffs.some((diff) => diff.file === path) + return diffs.some((diff) => filePathEqual(diff.file, path)) } const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => { @@ -193,7 +194,7 @@ export const PromptInput: Component = (props) => { requestAnimationFrame(() => { const current = comments.focus() if (!current) return - if (current.file !== focus.file || current.id !== focus.id) return + if (!filePathEqual(current.file, focus.file) || current.id !== focus.id) return schedule(left - 1) }) }) @@ -229,8 +230,9 @@ export const PromptInput: Component = (props) => { for (const tab of order) { const path = files.pathFromTab(tab) if (!path) continue - if (seen.has(path)) continue - seen.add(path) + const key = filePathKey(path) + if (seen.has(key)) continue + seen.add(key) paths.push(path) } @@ -343,13 +345,13 @@ export const PromptInput: Component = (props) => { ) const historyComments = () => { - const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) + const byID = new Map(comments.all().map((item) => [`${filePathKey(item.file)}\n${item.id}`, item] as const)) return prompt.context.items().flatMap((item) => { if (item.type !== "file") return [] const comment = item.comment?.trim() if (!comment) return [] - const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined + const selection = item.commentID ? byID.get(`${filePathKey(item.path)}\n${item.commentID}`)?.selection : undefined const nextSelection = selection ?? (item.selection @@ -366,7 +368,7 @@ export const PromptInput: Component = (props) => { path: item.path, selection: { ...nextSelection }, comment, - time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), + time: item.commentID ? (byID.get(`${filePathKey(item.path)}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), origin: item.commentOrigin, preview: item.preview, } satisfies PromptHistoryComment, @@ -557,7 +559,7 @@ export const PromptInput: Component = (props) => { const atKey = (x: AtOption | undefined) => { if (!x) return "" - return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` + return x.type === "agent" ? `agent:${x.name}` : `file:${filePathKey(x.path)}` } const { @@ -570,12 +572,17 @@ export const PromptInput: Component = (props) => { items: async (query) => { const agents = agentList() const open = recent() - const seen = new Set(open) - const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + const seen = new Set(open.map(filePathKey)) + const pinned: AtOption[] = open.map((path) => ({ + type: "file", + path, + display: files.display(path), + recent: true, + })) const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths - .filter((path) => !seen.has(path)) - .map((path) => ({ type: "file", path, display: path })) + .filter((path) => !seen.has(filePathKey(path))) + .map((path) => ({ type: "file", path, display: files.display(path) })) return [...agents, ...pinned, ...fileOptions] }, key: atKey, @@ -1293,7 +1300,7 @@ export const PromptInput: Component = (props) => { items={contextItems()} active={(item) => { const active = comments.active() - return !!item.commentID && item.commentID === active?.id && item.path === active?.file + return !!item.commentID && item.commentID === active?.id && filePathEqual(item.path, active?.file) }} openComment={openComment} remove={(item) => { 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 4146fb4847fc..4e7a90e0628a 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -1,7 +1,6 @@ -import { getFilename } from "@opencode-ai/util/path" +import { encodeFilePath, getFilename, resolveWorkspacePath } from "@opencode-ai/util/path" import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" import type { FileSelection } from "@/context/file" -import { encodeFilePath } from "@/context/file/path" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import { Identifier } from "@/utils/id" import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note" @@ -29,13 +28,6 @@ type BuildRequestPartsInput = { sessionDirectory: string } -const absolute = (directory: string, path: string) => { - if (path.startsWith("/")) return path - if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path - if (path.startsWith("\\\\") || path.startsWith("//")) return path - return `${directory.replace(/[\\/]+$/, "")}/${path}` -} - const fileQuery = (selection: FileSelection | undefined) => selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" @@ -88,7 +80,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) { ] const files = input.prompt.filter(isFileAttachment).map((attachment) => { - const path = absolute(input.sessionDirectory, attachment.path) + const path = resolveWorkspacePath(input.sessionDirectory, attachment.path) return { id: Identifier.ascending("part"), type: "file", @@ -122,7 +114,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 path = resolveWorkspacePath(input.sessionDirectory, item.path) const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}` const comment = item.comment?.trim() if (!comment && used.has(url)) return [] diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index b138fe3ef690..0529f5273aa0 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -3,6 +3,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" +import { useFile } from "@/context/file" import type { ContextItem } from "@/context/prompt" type PromptContextItem = ContextItem & { key: string } @@ -16,14 +17,17 @@ type ContextItemsProps = { } export const PromptContextItems: Component = (props) => { + const file = useFile() + return ( 0}>
{(item) => { - const directory = getDirectory(item.path) - const filename = getFilename(item.path) - const label = getFilenameTruncated(item.path, 14) + const path = file.display(item.path) + const directory = getDirectory(path) + const filename = getFilename(path) + const label = getFilenameTruncated(path, 14) const selected = props.active(item) return ( diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts index 37b5ce196279..f2f29115f5d0 100644 --- a/packages/app/src/components/prompt-input/history.test.ts +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -39,6 +39,10 @@ describe("prompt-input history", () => { const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")]) expect(dedupedComments).toBe(commentsOnly) + + const withSlash = prependHistoryEntry([], [{ type: "file", path: "src\\a.ts", content: "@a", start: 0, end: 2 }]) + const dedupedSlash = prependHistoryEntry(withSlash, [{ type: "file", path: "src/a.ts", content: "@a", start: 0, end: 2 }]) + expect(dedupedSlash).toBe(withSlash) }) test("navigatePromptHistory restores saved prompt when moving down from newest", () => { @@ -95,6 +99,12 @@ describe("prompt-input history", () => { expect(up.entry.comments).toEqual([comment("c1")]) }) + test("comment equality ignores slash variants", () => { + const first = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")]) + const second = prependHistoryEntry(first, DEFAULT_PROMPT, [{ ...comment("c1"), path: "src\\a.ts" }]) + expect(second).toBe(first) + }) + test("normalizePromptHistoryEntry supports legacy prompt arrays", () => { const entry = normalizePromptHistoryEntry(text("legacy")) expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy") diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts index de62653211dd..7e1e3fc71bb3 100644 --- a/packages/app/src/components/prompt-input/history.ts +++ b/packages/app/src/components/prompt-input/history.ts @@ -1,5 +1,6 @@ import type { Prompt } from "@/context/prompt" import type { SelectedLineRange } from "@/context/file" +import { filePathEqual } from "@/context/file/path" const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] @@ -101,7 +102,7 @@ export function prependHistoryEntry( function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) { return ( - commentA.path === commentB.path && + filePathEqual(commentA.path, commentB.path) && commentA.comment === commentB.comment && commentA.origin === commentB.origin && commentA.preview === commentB.preview && @@ -122,7 +123,7 @@ function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistory if (partA.type !== partB.type) return false if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false if (partA.type === "file") { - if (partA.path !== (partB.type === "file" ? partB.path : "")) return false + if (!filePathEqual(partA.path, partB.type === "file" ? partB.path : "")) return false const a = partA.selection const b = partB.type === "file" ? partB.selection : undefined const sameSelection = diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 65eb01c797b8..4fd22ab62e78 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -70,8 +70,8 @@ export const PromptPopover: Component = (props) => { } const isDirectory = item.path.endsWith("/") - const directory = isDirectory ? item.path : getDirectory(item.path) - const filename = isDirectory ? "" : getFilename(item.path) + const directory = isDirectory ? item.display : getDirectory(item.display) + const filename = isDirectory ? "" : getFilename(item.display) return ( @@ -281,17 +286,18 @@ export const SessionReview = (props: SessionReviewProps) => {
- + {(file) => { let wrapper: HTMLDivElement | undefined - const item = createMemo(() => diffs().get(file)!) + const item = createMemo(() => diffs().get(key(file))!) + const dir = createMemo(() => getDirectory(getRelativeDisplayPath(file, data.directory))) - const expanded = createMemo(() => open().includes(file)) + const expanded = createMemo(() => open().some((item) => pathEqual(item, file))) const force = () => !!store.force[file] - const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file)) + const comments = createMemo(() => (props.comments ?? []).filter((c) => pathEqual(c.file, file))) const commentedLines = createMemo(() => comments().map((c) => c.selection)) const beforeText = () => (typeof item().before === "string" ? item().before : "") @@ -313,13 +319,13 @@ export const SessionReview = (props: SessionReviewProps) => { const selectedLines = createMemo(() => { const current = selection() - if (!current || current.file !== file) return null + if (!current || !pathEqual(current.file, file)) return null return current.range }) const draftRange = createMemo(() => { const current = commenting() - if (!current || current.file !== file) return null + if (!current || !pathEqual(current.file, file)) return null return current.range }) @@ -330,7 +336,7 @@ export const SessionReview = (props: SessionReviewProps) => { state: { opened: () => { const current = opened() - if (!current || current.file !== file) return null + if (!current || !pathEqual(current.file, file)) return null return current.id }, setOpened: (id) => setStore("opened", id ? { file, id } : null), @@ -377,7 +383,7 @@ export const SessionReview = (props: SessionReviewProps) => { }) onCleanup(() => { - anchors.delete(file) + anchors.delete(key(file)) }) const handleLineSelected = (range: SelectedLineRange | null) => { @@ -396,7 +402,7 @@ export const SessionReview = (props: SessionReviewProps) => { id={diffId(file)} data-file={file} data-slot="session-review-accordion-item" - data-selected={props.focusedFile === file ? "" : undefined} + data-selected={props.focusedFile && pathEqual(props.focusedFile, file) ? "" : undefined} > @@ -404,8 +410,8 @@ export const SessionReview = (props: SessionReviewProps) => {
- - {`\u202A${getDirectory(file)}\u202C`} + + {`\u202A${dir()}\u202C`} {getFilename(file)} @@ -461,7 +467,7 @@ export const SessionReview = (props: SessionReviewProps) => { data-slot="session-review-diff-wrapper" ref={(el) => { wrapper = el - anchors.set(file, el) + anchors.set(key(file), el) }} > diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 8c9c1ffe4030..a34053c3962c 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -4,7 +4,7 @@ import { useData } from "../context" import { useFileComponent } from "../context/file" import { Binary } from "@opencode-ai/util/binary" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename, getRelativeDisplayPath, pathEqual, pathKey } from "@opencode-ai/util/path" import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" @@ -87,6 +87,8 @@ function list(value: T[] | undefined | null, fallback: T[]) { const hidden = new Set(["todowrite", "todoread"]) +const key = (path: string) => pathKey(path) + function partState(part: PartType, showReasoningSummaries: boolean) { if (part.type === "tool") { if (hidden.has(part.tool)) return @@ -233,8 +235,9 @@ export function SessionTurn( const seen = new Set() return files .reduceRight((result, diff) => { - if (seen.has(diff.file)) return result - seen.add(diff.file) + const id = key(diff.file) + if (seen.has(id)) return result + seen.add(id) result.push(diff) return result }, []) @@ -247,6 +250,11 @@ export function SessionTurn( }) const open = () => state.open const expanded = () => state.expanded + const visible = createMemo(() => + diffs() + .map((diff) => diff.file) + .filter((file) => expanded().some((item) => pathEqual(item, file))), + ) createEffect( on( @@ -451,14 +459,15 @@ export function SessionTurn( setState("expanded", Array.isArray(value) ? value : value ? [value] : []) } > {(diff) => { - const active = createMemo(() => expanded().includes(diff.file)) + const active = createMemo(() => expanded().some((item) => pathEqual(item, diff.file))) + const dir = createMemo(() => getDirectory(getRelativeDisplayPath(diff.file, data.directory))) const [visible, setVisible] = createSignal(false) createEffect( @@ -485,10 +494,8 @@ export function SessionTurn(
- - - {`\u202A${getDirectory(diff.file)}\u202C`} - + + {`\u202A${dir()}\u202C`} {getFilename(diff.file)} diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index e116199eb233..b5b67a94e4e9 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,8 +1,9 @@ -import type { Message, Session, Part, FileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2" +import type { FileDiff, Message, Part, Path, ProviderListResponse, Session, SessionStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" type Data = { + path?: Path provider?: ProviderListResponse session: Session[] session_status: { @@ -41,6 +42,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ get directory() { return props.directory }, + get path() { + return props.data.path + }, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, } diff --git a/packages/util/src/path.test.ts b/packages/util/src/path.test.ts new file mode 100644 index 000000000000..d227f9b9e4b7 --- /dev/null +++ b/packages/util/src/path.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test" +import { + decodeFilePath, + encodeFilePath, + getDirectory, + getFilename, + joinPath, + normalizeInputPath, + getParentPath, + getPathDisplay, + getPathDisplaySeparator, + getPathRoot, + getPathScope, + getPathSearchText, + getPathSeparator, + getRelativeDisplayPath, + getWorkspaceRelativePath, + resolveWorkspacePath, + stripFileProtocol, + stripQueryAndHash, + trimPath, + trimPrettyPath, + unquoteGitPath, +} from "./path" + +describe("path display helpers", () => { + test("keeps posix separators in displayed directories", () => { + expect(getDirectory("src/components/app.tsx")).toBe("src/components/") + expect(getDirectory("/tmp/demo/app.ts")).toBe("/tmp/demo/") + }) + + test("keeps windows separators in displayed directories", () => { + expect(getDirectory("src\\components\\app.tsx")).toBe("src\\components\\") + expect(getDirectory("C:\\repo\\src\\app.tsx")).toBe("C:\\repo\\src\\") + expect(getDirectory("\\\\server\\share\\repo\\app.tsx")).toBe("\\\\server\\share\\repo\\") + }) + + test("extracts filenames across separator styles", () => { + expect(getFilename("src/components/app.tsx")).toBe("app.tsx") + expect(getFilename("src\\components\\app.tsx")).toBe("app.tsx") + }) + + test("infers native-looking separators for windows paths", () => { + expect(getPathSeparator("/tmp/demo")).toBe("/") + expect(getPathSeparator("C:/repo/src/app.tsx")).toBe("\\") + expect(getPathSeparator("\\\\server\\share\\repo")).toBe("\\") + }) + + test("keeps UNC roots stable for lexical navigation", () => { + 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 windows root forms stable for lexical navigation", () => { + expect(getPathRoot("C:")).toBe("C:/") + expect(getParentPath("C:")).toBe("C:/") + expect(getParentPath("C:/")).toBe("C:/") + }) + + test("normalizes input paths separately from pretty stored paths", () => { + expect(normalizeInputPath("C:")).toBe("C:/") + expect(trimPath("\\\\server\\share\\repo\\")).toBe("//server/share/repo") + expect(trimPrettyPath("C:/Users/dev/repo/")).toBe("C:\\Users\\dev\\repo") + expect(trimPrettyPath("\\\\server\\share\\repo\\")).toBe("\\\\server\\share\\repo") + }) + + test("joins pretty paths with native separators", () => { + expect(joinPath("/Users/dev", "repo/src")).toBe("/Users/dev/repo/src") + expect(joinPath("C:\\Users\\dev", "repo/src")).toBe("C:\\Users\\dev\\repo\\src") + expect(joinPath("\\\\server\\share", "repo")).toBe("\\\\server\\share\\repo") + expect(joinPath("C:\\Users\\dev", "C:/tmp/demo")).toBe("C:\\tmp\\demo") + }) + + test("builds picker display text with tilde and native separators", () => { + expect(getPathDisplay("/Users/dev/repo", "", "/Users/dev")).toBe("~/repo") + expect(getPathDisplaySeparator("~/repo", "/Users/dev")).toBe("/") + expect(getPathDisplay("C:/Users/dev/repo", "", "C:\\Users\\dev")).toBe("~\\repo") + expect(getPathDisplay("//server/share/repo", "\\\\server\\", "C:\\Users\\dev")).toBe( + "\\\\server\\share\\repo", + ) + }) + + test("scopes picker input from home or absolute roots", () => { + expect(getPathScope("\\\\server\\share\\repo", "C:/Users/dev", "C:/Users/dev")).toEqual({ + directory: "\\\\server\\share", + path: "repo", + }) + expect(getPathScope("~/code", "C:/Users/dev", "C:/Users/dev")).toEqual({ + directory: "C:\\Users\\dev", + path: "code", + }) + }) + + test("indexes search text in absolute, native, and filename 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") + }) + + test("relativizes display paths from the workspace root", () => { + expect(getRelativeDisplayPath("/repo/src/app.ts", "/repo")).toBe("/src/app.ts") + expect(getRelativeDisplayPath("C:\\repo\\src\\", "C:\\repo")).toBe("\\src\\") + expect(getRelativeDisplayPath("src/app.ts", "C:\\repo")).toBe("src\\app.ts") + expect(getRelativeDisplayPath("src\\app.ts", "/repo")).toBe("src/app.ts") + expect(getRelativeDisplayPath("C:/other/app.ts", "C:\\repo")).toBe("C:\\other\\app.ts") + expect(getRelativeDisplayPath("/other/app.ts", "/repo")).toBe("/other/app.ts") + }) + + test("resolves and relativizes workspace paths across platforms", () => { + expect(resolveWorkspacePath("/repo", "src/app.ts")).toBe("/repo/src/app.ts") + expect(resolveWorkspacePath("C:\\repo", "src\\app.ts")).toBe("C:\\repo\\src\\app.ts") + expect(resolveWorkspacePath("\\\\server\\share\\repo", "src\\app.ts")).toBe("\\\\server\\share\\repo\\src\\app.ts") + expect(resolveWorkspacePath("/repo", "/tmp/app.ts")).toBe("/tmp/app.ts") + + expect(getWorkspaceRelativePath("/repo/src/app.ts", "/repo")).toBe("src/app.ts") + expect(getWorkspaceRelativePath("C:/repo/src/app.ts", "C:\\repo")).toBe("src/app.ts") + expect(getWorkspaceRelativePath("c:\\repo\\src\\app.ts", "C:\\repo")).toBe("src\\app.ts") + expect(getWorkspaceRelativePath("//server/share/repo/src/app.ts", "\\\\server\\share\\repo")).toBe("src/app.ts") + expect(getWorkspaceRelativePath("/tmp/app.ts", "/repo")).toBe("/tmp/app.ts") + }) + + test("handles shared file-uri and git path decoding", () => { + expect(stripFileProtocol("file:///repo/src/app.ts")).toBe("/repo/src/app.ts") + expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts") + expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts") + expect(decodeFilePath("src/file%23name%20here.ts")).toBe("src/file#name here.ts") + expect(decodeFilePath("src/%ZZ/file.ts")).toBe("src/%ZZ/file.ts") + expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt") + expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname") + }) + + test("encodes file paths for file URIs", () => { + expect(encodeFilePath("/path/to/file#name.txt")).toBe("/path/to/file%23name.txt") + expect(encodeFilePath("C:\\Users\\test\\file with spaces.txt")).toBe("/C:/Users/test/file%20with%20spaces.txt") + expect(encodeFilePath("src\\app.ts")).toBe("src/app.ts") + }) +}) diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts index bb191f5120ab..72dda884a9ed 100644 --- a/packages/util/src/path.ts +++ b/packages/util/src/path.ts @@ -1,3 +1,12 @@ +/** + * UI/string-oriented path helpers shared across packages. + * + * This file normalizes separators, display text, and lightweight comparisons, + * but it is not the source of truth for filesystem-aware resolution, + * `file://` parsing, or Windows alias handling. Those semantics live in + * `packages/opencode/src/path/path.ts`. + */ + export function getFilename(path: string | undefined) { if (!path) return "" const trimmed = path.replace(/[\/\\]+$/, "") @@ -5,11 +14,379 @@ export function getFilename(path: string | undefined) { return parts[parts.length - 1] ?? "" } +const normalizeSlashes = (path: string) => path.replace(/[\\/]+/g, "/") + +const isUncPath = (path: string) => /^[\\/]{2}[^\\/]/.test(path) + +const isWindowsDrivePath = (path: string) => /^[A-Za-z]:([\\/]|$)/.test(path) + +const normalizeDrive = (path: string) => { + const normalized = normalizePath(path) + if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}/` + return normalized +} + +const trimDisplay = (path: string) => { + const separator = getPathSeparator(path) + return separator === "/" ? path : path.replaceAll("/", "\\") +} + +export function normalizeInputPath(path: string) { + return normalizeDrive(path) +} + +export function trimPath(path: string) { + const normalized = normalizeDrive(path) + if (normalized === "/" || normalized === "//") return normalized + if (/^[A-Za-z]:\/$/.test(normalized)) return normalized + return normalized.replace(/\/+$/, "") +} + +export function trimPrettyPath(path: string) { + const trimmed = trimPath(path) + if (!trimmed) return "" + return trimDisplay(trimmed) +} + +export function joinPath(base: string | undefined, path: string) { + if (getPathRoot(path)) return trimPrettyPath(path) + + const root = trimPrettyPath(base ?? "") + const leaf = trimPath(path).replace(/^\/+/, "") + if (!root) return trimDisplay(leaf) + if (!leaf) return root + + const separator = getPathSeparator(root) + const value = separator === "/" ? leaf : leaf.replaceAll("/", "\\") + if (root.endsWith(separator)) return root + value + return `${root}${separator}${value}` +} + +const mode = (path: string) => { + const normalized = normalizeDrive(path.trim()) + if (!normalized) return "relative" as const + if (normalized.startsWith("~")) return "tilde" as const + if (getPathRoot(normalized)) return "absolute" as const + return "relative" as const +} + +const fold = (path: string) => { + const normalized = normalizePath(path) + if (isWindowsDrivePath(normalized) || isUncPath(normalized)) return normalized.toLowerCase() + return normalized +} + +const native = (path: string, home: string) => { + if (getPathDisplaySeparator(path, home) === "/") return path + return path.replaceAll("/", "\\") +} + +const trailing = (path: string, home: string) => { + if (!path) return "" + const separator = getPathDisplaySeparator(path, home) + if (path.endsWith(separator)) return path + return path + separator +} + +const resep = (path: string, separator: "/" | "\\", trailing: boolean) => { + const text = normalizePath(path).replace(/\/+$/, "") + if (!text) return trailing ? separator : "" + const value = separator === "/" ? text : text.replaceAll("/", "\\") + return trailing ? value + separator : value +} + +export function normalizePath(path: string) { + if (!path) return "" + if (isUncPath(path)) return `//${path.slice(2).replace(/[\\/]+/g, "/")}` + return normalizeSlashes(path) +} + +export function stripFileProtocol(input: string) { + if (!input.startsWith("file://")) return input + return input.slice("file://".length) +} + +export function stripQueryAndHash(input: string) { + const hash = input.indexOf("#") + const query = input.indexOf("?") + + if (hash !== -1 && query !== -1) { + return input.slice(0, Math.min(hash, query)) + } + + if (hash !== -1) return input.slice(0, hash) + if (query !== -1) return input.slice(0, query) + return input +} + +export function unquoteGitPath(input: string) { + if (!input.startsWith('"')) return input + if (!input.endsWith('"')) return input + const body = input.slice(1, -1) + const bytes: number[] = [] + + for (let i = 0; i < body.length; i++) { + const char = body[i]! + if (char !== "\\") { + bytes.push(char.charCodeAt(0)) + continue + } + + const next = body[i + 1] + if (!next) { + bytes.push("\\".charCodeAt(0)) + continue + } + + if (next >= "0" && next <= "7") { + const chunk = body.slice(i + 1, i + 4) + const match = chunk.match(/^[0-7]{1,3}/) + if (!match) { + bytes.push(next.charCodeAt(0)) + i++ + continue + } + bytes.push(parseInt(match[0], 8)) + i += match[0].length + continue + } + + const escaped = + next === "n" + ? "\n" + : next === "r" + ? "\r" + : next === "t" + ? "\t" + : next === "b" + ? "\b" + : next === "f" + ? "\f" + : next === "v" + ? "\v" + : next === "\\" || next === '"' + ? next + : undefined + + bytes.push((escaped ?? next).charCodeAt(0)) + i++ + } + + return new TextDecoder().decode(new Uint8Array(bytes)) +} + +export function decodeFilePath(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +export function resolveWorkspacePath(root: string, path: string) { + if (!path) return path + if (getPathRoot(path)) return path + if (!root) return path + + const base = root.replace(/[\\/]+$/, "") + if (base) return `${base}${getPathSeparator(root)}${path}` + + const prefix = trimPath(root) + if (!prefix) return path + if (prefix.endsWith("/")) return prefix + path + return `${prefix}/${path}` +} + +export function getWorkspaceRelativePath(path: string, root: string) { + if (!root) return path + + const base = trimPath(root) + if (!base) return path + + const windows = isWindowsDrivePath(base) || isUncPath(base) + const canonRoot = windows ? base.replace(/\\/g, "/").toLowerCase() : base.replace(/\\/g, "/") + const canonPath = windows ? path.replace(/\\/g, "/").toLowerCase() : path.replace(/\\/g, "/") + + if (!canonPath.startsWith(canonRoot)) return path + if (!canonRoot.endsWith("/")) { + const next = canonPath[canonRoot.length] + if (next && next !== "/") return path + } + + return path.slice(base.length).replace(/^[\\/]+/, "") +} + +export function getPathSeparator(path: string | undefined) { + if (!path) return "/" + if (path.includes("\\") || isWindowsDrivePath(path) || isUncPath(path)) return "\\" + return "/" +} + +export function getPathRoot(path: string) { + const normalized = normalizeDrive(path) + if (normalized.startsWith("//")) { + const parts = normalized + .slice(2) + .replace(/^\/+/, "") + .split("/") + .filter(Boolean) + if (parts.length === 0) return "//" + if (parts.length === 1) return `//${parts[0]}` + return `//${parts[0]}/${parts[1]}` + } + if (normalized.startsWith("/")) return "/" + if (/^[A-Za-z]:\//.test(normalized)) return normalized.slice(0, 3) + return "" +} + +export function pathKey(path: string) { + if (!path) return "" + + if (isUncPath(path)) { + const normalized = normalizeSlashes(path).replace(/^\/+/, "").replace(/\/+$/, "") + return normalized ? `//${normalized.toLowerCase()}` : "//" + } + + const normalized = normalizeSlashes(path).replace(/\/+$/, "") + + if (isWindowsDrivePath(path)) { + const folded = normalized.toLowerCase() + return /^[a-z]:$/.test(folded) ? `${folded}/` : folded + } + + if (!normalized && /[\\/]/.test(path)) return "/" + return normalized +} + +export function pathEqual(a: string | undefined, b: string | undefined) { + if (!a || !b) return a === b + return pathKey(a) === pathKey(b) +} + +export function getParentPath(path: string) { + if (!path) return "" + const normalized = trimPath(path) + if (normalized === "/" || normalized === "//") return normalized + if (/^[A-Za-z]:\/$/.test(normalized)) return normalized + + const root = getPathRoot(normalized) + if (root && normalized === root) return root + + const idx = normalized.lastIndexOf("/") + if (idx < 0) return root + if (idx === 0) return "/" + if (idx === 2 && /^[A-Za-z]:/.test(normalized)) return normalized.slice(0, 3) + + const parent = normalized.slice(0, idx) + if (root && parent.length < root.length) return root + return parent || root || "/" +} + +const getTildePath = (path: string, home: string) => { + const full = trimPath(path) + const base = trimPath(home) + if (!base) return "" + if (fold(full) === fold(base)) return "~" + if (fold(full).startsWith(fold(base + "/"))) return `~${full.slice(base.length)}` + return "" +} + +export function getPathDisplaySeparator(path: string, home: string) { + if (mode(path) === "absolute") return getPathSeparator(path) + return getPathSeparator(home || path) +} + +export function getPathDisplay(path: string, input: string, home: string) { + const full = trimPath(path) + const value = mode(input) === "absolute" ? full : getTildePath(full, home) || full + return native(value, home) +} + +export function getPathSearchText(path: string, home: string) { + const full = trimPath(path) + const tilde = getTildePath(full, home) + const absolute = native(full, home) + const shown = tilde ? native(tilde, home) : "" + + return Array.from( + new Set( + [ + full, + trailing(full, home), + absolute, + trailing(absolute, home), + tilde, + trailing(tilde, home), + shown, + trailing(shown, home), + getFilename(full), + ].filter(Boolean), + ), + ).join("\n") +} + +export function getPathScope(input: string, start: string | undefined, home: string) { + const base = start ? trimPrettyPath(start) : "" + if (!base) return + + const normalized = normalizeInputPath(input) + if (!normalized) return { directory: base, path: "" } + if (normalized === "~") return { directory: trimPrettyPath(home || base), path: "" } + if (normalized.startsWith("~/")) return { directory: trimPrettyPath(home || base), path: normalized.slice(2) } + + const root = getPathRoot(normalized) + if (!root) return { directory: base, path: normalized } + return { + directory: trimPrettyPath(root), + path: normalized.slice(root.length).replace(/^\/+/, ""), + } +} + +export function getRelativeDisplayPath(path: string, root?: string) { + if (!path) return "" + if (!root) return path + if (root === "/" || root === "\\") return resep(path, getPathSeparator(root), /[\\/]+$/.test(path)) + + const separator = getPathSeparator(root) + const trailing = /[\\/]+$/.test(path) + const full = normalizePath(path).replace(/\/+$/, "") + const base = normalizePath(root).replace(/\/+$/, "") + if (!base) return resep(path, separator, trailing) + if (!getPathRoot(full)) return resep(full, separator, trailing) + if (fold(full) === fold(base)) return trailing ? separator : "" + + const prefix = `${base}/` + if (!fold(full).startsWith(fold(prefix))) return resep(full, separator, trailing) + + const relative = full.slice(base.length).replace(/^\/+/, "") + if (!relative) return trailing ? separator : "" + + const value = separator + relative.replaceAll("/", separator) + return trailing ? value + separator : value +} + +export function encodeFilePath(filepath: string): string { + let normalized = filepath.replace(/\\/g, "/") + + if (/^[A-Za-z]:/.test(normalized)) { + normalized = "/" + normalized + } + + return normalized + .split("/") + .map((segment, index) => { + if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment + return encodeURIComponent(segment) + }) + .join("/") +} + export function getDirectory(path: string | undefined) { if (!path) return "" const trimmed = path.replace(/[\/\\]+$/, "") - const parts = trimmed.split(/[\/\\]/) - return parts.slice(0, parts.length - 1).join("/") + "/" + const idx = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")) + if (idx < 0) return "" + return trimmed.slice(0, idx + 1) || getPathSeparator(path) } export function getFileExtension(path: string | undefined) {