mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-04 06:44:27 +00:00
fix: reuse current location for directory browsing (#46970)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com> Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
This commit is contained in:
parent
cfe9f13963
commit
b42555cf01
16 changed files with 539 additions and 135 deletions
|
|
@ -53,9 +53,8 @@ test("creates a session in a new project and selects its model", async ({ page }
|
|||
}),
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
fileList: (path) =>
|
||||
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
|
||||
findFiles: () => ["NewProject"],
|
||||
// Listings are requested by absolute path and returned relative to the stable Location.
|
||||
fileList: (path) => (path === "C:/OpenCode" ? [{ path: "./", type: "directory", ignored: false }] : []),
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
|
||||
|
|
@ -76,12 +75,23 @@ test("creates a session in a new project and selects its model", async ({ page }
|
|||
const addProject = page.locator('[data-action="home-add-project-row"]')
|
||||
await expectAppVisible(addProject)
|
||||
await addProject.click()
|
||||
const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
|
||||
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
|
||||
await expect(picker.getByRole("combobox")).toHaveValue("C:\\OpenCode\\NewProject")
|
||||
const listing = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return url.pathname === "/api/fs/list" && url.searchParams.get("path") === "C:/OpenCode"
|
||||
})
|
||||
await picker.getByRole("button", { name: "Parent", exact: true }).click()
|
||||
expect(new URL((await listing).url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
const directoryItem = picker.getByRole("treeitem", { name: "NewProject", exact: true })
|
||||
await expect(directoryItem).toBeVisible()
|
||||
await directoryItem.click()
|
||||
const selectFolder = page.getByRole("button", { name: "Select folder" })
|
||||
await expect(directoryItem).toHaveAttribute("aria-selected", "true")
|
||||
await expect(picker.getByText("C:\\OpenCode\\NewProject", { exact: true })).toBeVisible()
|
||||
const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true })
|
||||
await expect(selectFolder).toBeEnabled()
|
||||
await selectFolder.click()
|
||||
await expect(picker).toBeHidden()
|
||||
|
||||
await page.locator('[data-action="home-new-session"]').click()
|
||||
await expectAppVisible(page.locator('[data-component="composer"]'))
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
|
|||
if (!connection) return
|
||||
pickDirectory({
|
||||
server: connection,
|
||||
location: ServerConnection.key(connection) === ServerConnection.key(projectServer()) ? location().ref : undefined,
|
||||
title,
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
|
|||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { Path } from "@/runtime/server/types"
|
||||
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
absoluteTreePath,
|
||||
activeTreeNavigation,
|
||||
|
|
@ -26,16 +26,19 @@ import {
|
|||
displayPickerPath,
|
||||
pickerParent,
|
||||
pickerRoot,
|
||||
listPickerDirectory,
|
||||
pickerRelativePath,
|
||||
pickerAbsolutePath,
|
||||
} from "./domain"
|
||||
import "./dialog.css"
|
||||
import { Divider } from "@opencode-ai/ui/divider"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
interface DirectoryPickerDialogProps {
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
onSelect: (result: string | string[] | null) => void
|
||||
server: ServerConnection.Any
|
||||
location?: LocationRef
|
||||
mode?: "directory" | "file"
|
||||
start?: string
|
||||
}
|
||||
|
|
@ -67,32 +70,24 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
|||
let navigation = 0
|
||||
|
||||
const [fallbackPath] = createResource(
|
||||
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
|
||||
() =>
|
||||
sdk.api.location
|
||||
.get()
|
||||
.then(
|
||||
(location): Path => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location.project.directory,
|
||||
directory: location.directory,
|
||||
home: "",
|
||||
}),
|
||||
)
|
||||
.catch(() => undefined),
|
||||
() => (props.location ? undefined : true),
|
||||
() => sdk.api.location.get().catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||
const home = createMemo(() => sync.data.path.home || "")
|
||||
const location = createMemo(() => {
|
||||
const current = props.location ?? fallbackPath()
|
||||
return current ? { directory: current.directory, workspace: current.workspaceID } : undefined
|
||||
})
|
||||
const start = createMemo(
|
||||
() =>
|
||||
props.start ||
|
||||
sync.data.path.home ||
|
||||
props.location?.directory ||
|
||||
sync.data.path.directory ||
|
||||
fallbackPath()?.home ||
|
||||
fallbackPath()?.directory,
|
||||
)
|
||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||
const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() })
|
||||
const [suggestions] = createResource(input, async (value) => {
|
||||
const cleaned = cleanPickerInput(value)
|
||||
const typed = cleaned.replace(/\/+$/, "")
|
||||
|
|
@ -100,12 +95,14 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
|||
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||
const base = pickerRoot(cleaned) || root() || start()
|
||||
const base = location()?.directory
|
||||
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||
const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base))
|
||||
if (query === undefined) return { query: value, items: directories.slice(0, 5) }
|
||||
const files = await sdk.api.file
|
||||
.find({
|
||||
location: { directory: base },
|
||||
query: pickerFileSearchQuery(base, value, home()),
|
||||
location: location(),
|
||||
query,
|
||||
type: "file",
|
||||
limit: 20,
|
||||
})
|
||||
|
|
@ -113,7 +110,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
|||
.catch(() => [])
|
||||
const results = [
|
||||
...directories,
|
||||
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
|
||||
...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })),
|
||||
]
|
||||
return {
|
||||
query: value,
|
||||
|
|
@ -132,15 +129,9 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
|||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
return sdk.api.file
|
||||
.list({ location: { directory: absolute } })
|
||||
.then((result) =>
|
||||
result.data.map((entry) => ({
|
||||
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
||||
type: entry.type,
|
||||
})),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
const current = location()
|
||||
if (!current) return Promise.resolve(undefined)
|
||||
return listPickerDirectory(sdk, current, absolute).catch(() => undefined)
|
||||
})
|
||||
listings.set(key, request)
|
||||
const nodes = await request
|
||||
|
|
@ -282,7 +273,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
|||
|
||||
createEffect(() => {
|
||||
const path = start()
|
||||
if (!path || root()) return
|
||||
if (!path || !location() || root()) return
|
||||
void navigate(path)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
currentPickerSuggestions,
|
||||
createDirectorySearch,
|
||||
createPriorityTaskQueue,
|
||||
listPickerDirectory,
|
||||
displayPickerPath,
|
||||
pickerParent,
|
||||
pickerRoot,
|
||||
|
|
@ -130,44 +131,181 @@ test("scopes file autocomplete to the current browser root", () => {
|
|||
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
|
||||
})
|
||||
|
||||
test("resolves directory autocomplete from the current browser root", async () => {
|
||||
const directories: string[] = []
|
||||
test("resolves directory autocomplete from the browser root without changing location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const location = { directory: "/repo", workspace: "workspace_1" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: (input: { location?: { directory?: string } }) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({ data: [] })
|
||||
find: (input: unknown) => {
|
||||
calls.push(input)
|
||||
return Promise.resolve({ location, data: [{ path: "src/components/", type: "directory" }] })
|
||||
},
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
let base = "/repo"
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base, location: () => location })
|
||||
|
||||
await search("components")
|
||||
expect(await search("components")).toEqual(["/repo/src/components"])
|
||||
base = "/repo/src"
|
||||
await search("components")
|
||||
expect(await search("components")).toEqual(["/repo/src/components"])
|
||||
|
||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
||||
expect(calls).toEqual([
|
||||
{ location, query: "components", type: "directory", limit: 50 },
|
||||
{ location, query: "src/components", type: "directory", limit: 50 },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps indexed directory results for servers that support empty search", async () => {
|
||||
test("lists absolute parents and preloads siblings through a stable workspace", async () => {
|
||||
const calls: unknown[] = []
|
||||
const location = { directory: "/repo/current", workspace: "workspace_1" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
|
||||
list: async (input: { path?: string }) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location,
|
||||
data:
|
||||
input.path === "/repo"
|
||||
? [
|
||||
{ path: "./", type: "directory" },
|
||||
{ path: "../sibling/", type: "directory" },
|
||||
]
|
||||
: [{ path: "../sibling/src/", type: "directory" }],
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof listPickerDirectory>[0]
|
||||
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
|
||||
{ name: "current", absolute: "/repo/current", type: "directory" },
|
||||
{ name: "sibling", absolute: "/repo/sibling", type: "directory" },
|
||||
])
|
||||
expect(await listPickerDirectory(sdk, location, "/repo/sibling")).toEqual([
|
||||
{ name: "src", absolute: "/repo/sibling/src", type: "directory" },
|
||||
])
|
||||
expect(calls).toEqual([
|
||||
{ location, path: "/repo" },
|
||||
{ location, path: "/repo/sibling" },
|
||||
])
|
||||
})
|
||||
|
||||
test("uses listings for typed searches outside the current location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const location = { directory: "/repo/current", workspace: "workspace_1" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.reject(new Error("outside searches must not change location")),
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { location, data: [{ path: "../sibling/", type: "directory" }] }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
|
||||
expect(await search("sib")).toEqual(["/repo/sibling"])
|
||||
expect(calls).toEqual([{ location, path: "/repo" }])
|
||||
})
|
||||
|
||||
test("keeps literal tilde directory names in server listing and search results", async () => {
|
||||
const location = { directory: "/repo" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
list: async () => ({ location, data: [{ path: "~/", type: "directory" }] }),
|
||||
find: async () => ({ location, data: [{ path: "~/nested/", type: "directory" }] }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof listPickerDirectory>[0]
|
||||
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
|
||||
{ name: "~", absolute: "/repo/~", type: "directory" },
|
||||
])
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/user", base: () => "/repo", location: () => location })
|
||||
expect(await search("nested")).toEqual(["/repo/~/nested"])
|
||||
})
|
||||
|
||||
test("discards stale typed results without changing the request location", async () => {
|
||||
const location = { directory: "/repo" }
|
||||
const pending = Promise.withResolvers<{
|
||||
location: typeof location
|
||||
data: Array<{ path: string; type: "directory" }>
|
||||
}>()
|
||||
const calls: unknown[] = []
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: async (input: { query: string }) => {
|
||||
calls.push(input)
|
||||
if (input.query === "old") return pending.promise
|
||||
return { location, data: [{ path: "new/", type: "directory" }] }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
|
||||
const stale = search("old")
|
||||
expect(await search("new")).toEqual(["/repo/new"])
|
||||
pending.resolve({ location, data: [{ path: "old/", type: "directory" }] })
|
||||
expect(await stale).toEqual([])
|
||||
expect(calls).toEqual([
|
||||
{ location, query: "old", type: "directory", limit: 50 },
|
||||
{ location, query: "new", type: "directory", limit: 50 },
|
||||
])
|
||||
})
|
||||
|
||||
test("maps server-native drive and share paths without rebasing the location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
list: async (input: { location: { directory: string }; path: string }) => {
|
||||
calls.push(input)
|
||||
return { location: input.location, data: [{ path: "../sibling/", type: "directory" }] }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof listPickerDirectory>[0]
|
||||
const drive = { directory: "C:\\Repo\\Current", workspace: "workspace_1" }
|
||||
expect(await listPickerDirectory(sdk, drive, "c:/repo")).toEqual([
|
||||
{ name: "sibling", type: "directory", absolute: "C:/Repo/sibling" },
|
||||
])
|
||||
const share = { directory: "\\\\Server\\Share\\Current", workspace: "workspace_2" }
|
||||
expect(await listPickerDirectory(sdk, share, "//server/share")).toEqual([
|
||||
{ name: "sibling", type: "directory", absolute: "//Server/Share/sibling" },
|
||||
])
|
||||
expect(calls).toEqual([
|
||||
{ location: drive, path: "c:/repo" },
|
||||
{ location: share, path: "//server/share" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps indexed directory results for servers that support empty search", async () => {
|
||||
const location = { directory: "/home/luke" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.resolve({ location, data: [{ path: "projects/", type: "directory" }] }),
|
||||
list: () => Promise.reject(new Error("listing should not run when search returns results")),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
||||
const search = createDirectorySearch({
|
||||
sdk,
|
||||
home: () => "/home/luke",
|
||||
base: () => "/home/luke",
|
||||
location: () => location,
|
||||
})
|
||||
|
||||
expect(await search("")).toEqual(["/home/luke/projects"])
|
||||
})
|
||||
|
||||
test("lists the default directory when empty search is unsupported", async () => {
|
||||
const location = { directory: "/home/luke" }
|
||||
const calls: string[] = []
|
||||
const directories = Array.from({ length: 60 }, (_, index) => ({
|
||||
path: `project-${index}/`,
|
||||
|
|
@ -180,13 +318,19 @@ test("lists the default directory when empty search is unsupported", async () =>
|
|||
list: (input: { location?: { directory?: string } }) => {
|
||||
calls.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({
|
||||
location,
|
||||
data: [...directories, { path: "README.md", type: "file" }],
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
||||
const search = createDirectorySearch({
|
||||
sdk,
|
||||
home: () => "/home/luke",
|
||||
base: () => "/home/luke",
|
||||
location: () => location,
|
||||
})
|
||||
|
||||
const results = await search("")
|
||||
expect(results).toHaveLength(60)
|
||||
|
|
@ -195,12 +339,14 @@ test("lists the default directory when empty search is unsupported", async () =>
|
|||
})
|
||||
|
||||
test("matches the default directory listing when typed search is unsupported", async () => {
|
||||
const location = { directory: "/home/luke" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.resolve({ data: [] }),
|
||||
list: () =>
|
||||
Promise.resolve({
|
||||
location,
|
||||
data: [
|
||||
{ path: "Documents/", type: "directory" },
|
||||
{ path: "Downloads/", type: "directory" },
|
||||
|
|
@ -209,12 +355,18 @@ test("matches the default directory listing when typed search is unsupported", a
|
|||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
||||
const search = createDirectorySearch({
|
||||
sdk,
|
||||
home: () => "/home/luke",
|
||||
base: () => "/home/luke",
|
||||
location: () => location,
|
||||
})
|
||||
|
||||
expect(await search("documents")).toEqual(["/home/luke/Documents"])
|
||||
})
|
||||
|
||||
test("searches from an absolute root without a default base", async () => {
|
||||
const location = { directory: "/" }
|
||||
const directories: string[] = []
|
||||
const sdk = {
|
||||
api: {
|
||||
|
|
@ -222,6 +374,7 @@ test("searches from an absolute root without a default base", async () => {
|
|||
list: (input: { location?: { directory?: string } }) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({
|
||||
location,
|
||||
data: [
|
||||
{ path: "Users/", type: "directory" },
|
||||
{ path: "tmp/", type: "directory" },
|
||||
|
|
@ -231,7 +384,7 @@ test("searches from an absolute root without a default base", async () => {
|
|||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
|
||||
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined, location: () => location })
|
||||
|
||||
expect(await search("/")).toEqual(["/Users", "/tmp"])
|
||||
expect(directories).toEqual(["/"])
|
||||
|
|
|
|||
|
|
@ -76,6 +76,11 @@ export function pickerFileSearchQuery(root: string, input: string, home: string)
|
|||
|
||||
export function pickerAbsoluteInput(input: string, home: string, current: string) {
|
||||
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
|
||||
return pickerAbsolutePath(value, current)
|
||||
}
|
||||
|
||||
export function pickerAbsolutePath(input: string, current: string) {
|
||||
const value = normalizePickerDrive(input)
|
||||
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
|
||||
return canonicalPickerPath(absolute)
|
||||
}
|
||||
|
|
@ -321,7 +326,24 @@ export function displayPickerPath(path: string, input: string, home: string) {
|
|||
return pickerTilde(value, home) || value
|
||||
}
|
||||
|
||||
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
|
||||
export async function listPickerDirectory(
|
||||
sdk: ServerSDK,
|
||||
location: { directory: string; workspace?: string },
|
||||
directory: string,
|
||||
) {
|
||||
const result = await sdk.api.file.list({ location, path: directory })
|
||||
return result.data.map((entry) => {
|
||||
const absolute = pickerAbsolutePath(entry.path, result.location.directory)
|
||||
return { name: getFilename(absolute), type: entry.type, absolute }
|
||||
})
|
||||
}
|
||||
|
||||
export function createDirectorySearch(args: {
|
||||
sdk: ServerSDK
|
||||
location: () => { directory: string; workspace?: string } | undefined
|
||||
base: () => string | undefined
|
||||
home: () => string
|
||||
}) {
|
||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||
let current = 0
|
||||
|
||||
|
|
@ -339,21 +361,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||
}
|
||||
|
||||
const directories = async (directory: string) => {
|
||||
const key = trimPickerPath(directory)
|
||||
const location = args.location()
|
||||
if (!location) return []
|
||||
const key = JSON.stringify([location, trimPickerPath(directory)])
|
||||
const existing = cache.get(key)
|
||||
if (existing) return existing
|
||||
const request = args.sdk.api.file
|
||||
.list({ location: { directory: key } })
|
||||
.then((result) => result.data)
|
||||
const request = listPickerDirectory(args.sdk, location, directory)
|
||||
.catch(() => [])
|
||||
.then((nodes) =>
|
||||
nodes
|
||||
.filter((node) => node.type === "directory")
|
||||
.map((node) => {
|
||||
const relative = trimPickerPath(normalizePickerDrive(node.path))
|
||||
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
|
||||
}),
|
||||
)
|
||||
.then((nodes) => nodes.filter((node) => node.type === "directory"))
|
||||
cache.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
|
@ -369,18 +384,27 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||
const active = () => token === current
|
||||
const value = cleanPickerInput(filter)
|
||||
const input = scoped(value)
|
||||
if (!input) return [] as string[]
|
||||
const location = args.location()
|
||||
if (!input || !location) return [] as string[]
|
||||
const raw = normalizePickerDrive(value)
|
||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||
const query = normalizePickerDrive(input.path)
|
||||
if (!pathInput) {
|
||||
const results = await args.sdk.api.file
|
||||
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||
.then((result) => result.data.map((entry) => entry.path))
|
||||
.catch(() => [])
|
||||
const relative = pickerRelativePath(location.directory, input.directory)
|
||||
const results =
|
||||
relative === undefined
|
||||
? []
|
||||
: await args.sdk.api.file
|
||||
.find({ location, query: joinPickerPath(relative, query), type: "directory", limit: 50 })
|
||||
.then((result) =>
|
||||
result.data
|
||||
.map((entry) => pickerAbsolutePath(entry.path, result.location.directory))
|
||||
.filter((path) => treePathWithin(input.directory, path)),
|
||||
)
|
||||
.catch(() => [])
|
||||
if (!active()) return []
|
||||
if (results.length) {
|
||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||
return results.slice(0, 50)
|
||||
}
|
||||
const fallback = query
|
||||
? await match(input.directory, query, 50)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ describe("directoryPickerKind", () => {
|
|||
test("uses the native picker only for local desktop projects", () => {
|
||||
expect(directoryPickerKind("desktop", local)).toBe("native")
|
||||
expect(directoryPickerKind("desktop", remote)).toBe("server")
|
||||
expect(directoryPickerKind("desktop", { ...local, variant: "wsl", distro: "Ubuntu" })).toBe("server")
|
||||
expect(directoryPickerKind("web", local)).toBe("server")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { lazy } from "solid-js"
|
||||
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||
import { directoryPickerKind } from "./policy"
|
||||
|
||||
const DirectoryPickerDialog = lazy(() =>
|
||||
|
|
@ -10,6 +11,7 @@ const DirectoryPickerDialog = lazy(() =>
|
|||
|
||||
type DirectoryPickerInput = {
|
||||
server: ServerConnection.Any
|
||||
location?: LocationRef
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
onSelect: (result: string | string[] | null) => void
|
||||
|
|
|
|||
|
|
@ -1559,7 +1559,7 @@ export interface PermissionApi<E = never> {
|
|||
|
||||
export type FileListInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly path?: RelativePath | undefined
|
||||
readonly path?: string | undefined
|
||||
}
|
||||
export type FileListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
|
||||
export type FileListOperation<E = never> = (input?: FileListInput) => Effect.Effect<FileListOutput, E>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const Content = Schema.Struct({
|
|||
export type Content = typeof Content.Type
|
||||
|
||||
export const ListInput = Schema.Struct({
|
||||
path: RelativePath.pipe(Schema.optional),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
|
|
@ -93,17 +93,18 @@ const baseLayer = Layer.effect(
|
|||
}
|
||||
}),
|
||||
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
// Navigation can leave the cwd without activating another Location.
|
||||
const directory = path.resolve(location.directory, input.path ?? ".")
|
||||
const info = yield* fs.stat(directory).pipe(Effect.orDie)
|
||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||
return yield* fs.readDirectoryEntries(target.real).pipe(
|
||||
return yield* fs.readDirectoryEntries(directory).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.flatMap((item) => {
|
||||
if (item.type !== "file" && item.type !== "directory") return []
|
||||
const absolute = path.join(target.absolute, item.name)
|
||||
const relative = path.relative(target.directory, absolute)
|
||||
const absolute = path.join(directory, item.name)
|
||||
const relative = path.relative(location.directory, absolute) || "."
|
||||
return [
|
||||
Entry.make({
|
||||
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
||||
|
|
|
|||
|
|
@ -83,6 +83,34 @@ describe("FileSystem", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("lists parents and siblings with paths relative to the current location", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const current = path.join(directory, "current")
|
||||
yield* Effect.promise(() => fs.mkdir(current))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "sibling")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "sibling", "file.txt"), "outside"))
|
||||
yield* Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const parent = yield* filesystem.list({ path: RelativePath.make("..") })
|
||||
expect(parent).toHaveLength(2)
|
||||
expect(parent.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ path: "." + path.sep, type: "directory" },
|
||||
{ path: path.join("..", "sibling") + path.sep, type: "directory" },
|
||||
]),
|
||||
)
|
||||
const sibling = yield* filesystem.list({ path: RelativePath.make("../sibling") })
|
||||
expect(sibling.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
|
||||
{ path: RelativePath.make(path.join("..", "sibling", "file.txt")), type: "file" },
|
||||
])
|
||||
const absolute = yield* filesystem.list({ path: path.join(directory, "sibling") })
|
||||
expect(absolute).toEqual(sibling)
|
||||
}).pipe(provide(current))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("canonicalizes local symlinked directories", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -105,10 +133,37 @@ describe("FileSystem", () => {
|
|||
it.live("rejects lexical escapes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
}).pipe(provide(directory)),
|
||||
const current = path.join(directory, "current")
|
||||
yield* Effect.promise(() => fs.mkdir(current))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "outside.txt"), "outside"))
|
||||
yield* Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
}).pipe(provide(current))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows listing through an external symlink without allowing file reads", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const current = path.join(directory, "current")
|
||||
const outside = path.join(directory, "outside")
|
||||
yield* Effect.promise(() => fs.mkdir(current))
|
||||
yield* Effect.promise(() => fs.mkdir(outside))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(outside, "file.txt"), "outside"))
|
||||
yield* Effect.promise(() => fs.symlink(outside, path.join(current, "link"), "junction"))
|
||||
yield* Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const entries = yield* filesystem.list({ path: RelativePath.make("link") })
|
||||
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
|
||||
{ path: RelativePath.make(path.join("link", "file.txt")), type: "file" },
|
||||
])
|
||||
const result = yield* filesystem.read({ path: RelativePath.make("link/file.txt") }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
}).pipe(provide(current))
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
|
||||
import { PositiveInt } from "@opencode-ai/schema/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
const ListQuery = Schema.Struct({
|
||||
...LocationQuery.fields,
|
||||
path: RelativePath.pipe(Schema.optional),
|
||||
path: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "An absolute path or a path relative to the requested location. Defaults to the location directory.",
|
||||
}),
|
||||
})
|
||||
|
||||
const FindQuery = Schema.Struct({
|
||||
|
|
@ -42,7 +44,8 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
|
|||
OpenApi.annotations({
|
||||
identifier: "v2.fs.list",
|
||||
summary: "List directory",
|
||||
description: "List direct children of one directory relative to the requested location.",
|
||||
description:
|
||||
"List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
20
packages/server/test/fixture/mcp-starts.cjs
Normal file
20
packages/server/test/fixture/mcp-starts.cjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
const fs = require("node:fs")
|
||||
const readline = require("node:readline")
|
||||
|
||||
fs.appendFileSync(process.argv[2], `${process.pid}\n`)
|
||||
readline
|
||||
.createInterface({ input: process.stdin })
|
||||
.on("line", (line) => {
|
||||
const request = JSON.parse(line)
|
||||
if (request.id === undefined) return
|
||||
const result =
|
||||
request.method === "initialize"
|
||||
? {
|
||||
protocolVersion: request.params.protocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "filesystem-test", version: "1" },
|
||||
}
|
||||
: { tools: [] }
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n")
|
||||
})
|
||||
.on("close", () => process.exit(0))
|
||||
79
packages/server/test/fs.test.ts
Normal file
79
packages/server/test/fs.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schedule } from "effect"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { startServer } from "./fixture/server"
|
||||
|
||||
it.live(
|
||||
"browsing parents and siblings reuses the current Location and its MCP process",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const current = path.join(tmp.path, "root", "current")
|
||||
const starts = path.join(tmp.path, "starts")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(current, { recursive: true })
|
||||
await fs.mkdir(path.join(tmp.path, "root", ".git"))
|
||||
await fs.mkdir(path.join(tmp.path, "root", "sibling", "nested"), { recursive: true })
|
||||
await fs.writeFile(path.join(tmp.path, "root", "sibling", "file.txt"), "outside")
|
||||
await fs.writeFile(starts, "")
|
||||
await fs.writeFile(
|
||||
path.join(tmp.path, "root", "opencode.json"),
|
||||
JSON.stringify({
|
||||
mcp: {
|
||||
servers: {
|
||||
filesystem: {
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture", "mcp-starts.cjs"), starts],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
const server = yield* startServer(path.join(tmp.path, "config"))
|
||||
const list = (directory: string) =>
|
||||
Effect.promise(async () => {
|
||||
const url = new URL("/api/fs/list", server.base)
|
||||
url.searchParams.set("location[directory]", current)
|
||||
url.searchParams.set("path", directory)
|
||||
const response = await fetch(url, { headers: server.headers })
|
||||
expect(response.status).toBe(200)
|
||||
const result = await response.json()
|
||||
expect(result.location.directory).toBe(current)
|
||||
return result.data
|
||||
})
|
||||
const loaded = Effect.promise(async () => {
|
||||
const response = await fetch(new URL("/api/debug/location", server.base), { headers: server.headers })
|
||||
expect(response.status).toBe(200)
|
||||
return response.json()
|
||||
})
|
||||
const count = Effect.promise(
|
||||
async () => (await fs.readFile(starts, "utf8")).trim().split("\n").filter(Boolean).length,
|
||||
)
|
||||
|
||||
yield* list(".")
|
||||
expect(yield* loaded).toEqual([{ directory: current }])
|
||||
expect(
|
||||
yield* count.pipe(
|
||||
Effect.repeat({ while: (n) => n === 0, schedule: Schedule.spaced("25 millis") }),
|
||||
Effect.timeout("5 seconds"),
|
||||
),
|
||||
).toBe(1)
|
||||
|
||||
yield* list("..")
|
||||
const sibling = yield* list("../sibling")
|
||||
expect(sibling).toEqual([
|
||||
{ path: path.join("..", "sibling", "nested") + path.sep, type: "directory" },
|
||||
{ path: path.join("..", "sibling", "file.txt"), type: "file" },
|
||||
])
|
||||
expect(yield* list(path.join(tmp.path, "root", "sibling"))).toEqual(sibling)
|
||||
yield* list("../sibling/nested")
|
||||
yield* list("../sibling")
|
||||
expect(yield* loaded).toEqual([{ directory: current }])
|
||||
expect(yield* count).toBe(1)
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
|
@ -25,13 +25,7 @@ import { Skill } from "@opencode-ai/schema/skill"
|
|||
import { stringWidth } from "../../util/string-width"
|
||||
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
|
||||
import { moveSelection, reconcileSelectionWindow, revealSelectionOffset } from "../../ui/select-controller"
|
||||
import {
|
||||
directoryAutocompleteExactValue,
|
||||
directoryAutocompleteMatches,
|
||||
directoryAutocompleteResultValue,
|
||||
directoryAutocompleteSearch,
|
||||
slashArgumentAutocomplete,
|
||||
} from "../../prompt/directory-completion"
|
||||
import { directoryAutocomplete, slashArgumentAutocomplete } from "../../prompt/directory-completion"
|
||||
|
||||
export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
|
|
@ -343,20 +337,38 @@ export function Autocomplete(props: {
|
|||
if (referenceMatch())
|
||||
return { options: [], failed: false, mode: input.visible, query: input.query, resolved: true }
|
||||
const { lineRange, base } = parseFileLineRange(input.query ?? "")
|
||||
const directorySearch =
|
||||
input.visible === "directory"
|
||||
? directoryAutocompleteSearch(base, input.location?.directory ?? paths.cwd, paths.home)
|
||||
: undefined
|
||||
|
||||
const requestLocation = {
|
||||
directory: directorySearch?.directory ?? input.location?.directory,
|
||||
directory: input.location?.directory,
|
||||
workspace: input.location?.workspaceID ?? data.location.default().workspaceID,
|
||||
}
|
||||
const result = await (
|
||||
input.visible === "directory"
|
||||
? client.api.file.list({ location: requestLocation })
|
||||
: client.api.file.find({ query: base, limit: 20, location: requestLocation })
|
||||
).then(
|
||||
const width = props.anchor().width - 4
|
||||
if (input.visible === "directory") {
|
||||
const result = await directoryAutocomplete(
|
||||
client.api.file,
|
||||
{ ...requestLocation, directory: requestLocation.directory ?? paths.cwd },
|
||||
base,
|
||||
paths.home,
|
||||
).catch(() => undefined)
|
||||
if (!result)
|
||||
return info.value?.mode === input.visible
|
||||
? { ...info.value, failed: true }
|
||||
: { options: [], failed: true, mode: input.visible, query: input.query, resolved: false }
|
||||
return {
|
||||
options: result.map((item) => ({
|
||||
display: Locale.truncateMiddle(item.value, width),
|
||||
value: item.value,
|
||||
isDirectory: true,
|
||||
path: item.value,
|
||||
absolute: item.absolute,
|
||||
onSelect: () => insertDirectory(item.value),
|
||||
})),
|
||||
failed: false,
|
||||
mode: input.visible,
|
||||
query: input.query,
|
||||
resolved: true,
|
||||
}
|
||||
}
|
||||
const result = await client.api.file.find({ query: base, limit: 20, location: requestLocation }).then(
|
||||
(result) => result,
|
||||
() => undefined,
|
||||
)
|
||||
|
|
@ -368,38 +380,8 @@ export function Autocomplete(props: {
|
|||
|
||||
const options: AutocompleteOption[] = []
|
||||
|
||||
const width = props.anchor().width - 4
|
||||
const exact = directorySearch ? directoryAutocompleteExactValue(base, directorySearch) : undefined
|
||||
if (exact) {
|
||||
options.push({
|
||||
display: Locale.truncateMiddle(exact, width),
|
||||
value: exact,
|
||||
isDirectory: true,
|
||||
path: exact,
|
||||
absolute: result.location.directory,
|
||||
onSelect: () => insertDirectory(exact),
|
||||
})
|
||||
}
|
||||
const entries =
|
||||
input.visible === "directory"
|
||||
? result.data.filter(
|
||||
(item) =>
|
||||
item.type === "directory" && directoryAutocompleteMatches(item.path, directorySearch?.query ?? ""),
|
||||
)
|
||||
: result.data
|
||||
options.push(
|
||||
...entries.map((item): AutocompleteOption => {
|
||||
if (input.visible === "directory") {
|
||||
const directory = directorySearch ? directoryAutocompleteResultValue(item.path, directorySearch) : item.path
|
||||
return {
|
||||
display: Locale.truncateMiddle(directory, width),
|
||||
value: directory,
|
||||
isDirectory: true,
|
||||
path: directory,
|
||||
absolute: path.resolve(result.location.directory, item.path),
|
||||
onSelect: () => insertDirectory(directory),
|
||||
}
|
||||
}
|
||||
...result.data.map((item): AutocompleteOption => {
|
||||
const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
|
||||
return {
|
||||
display: Locale.truncateMiddle(filename, width),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,31 @@
|
|||
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
|
||||
import type { OpenCodeClient } from "@opencode-ai/client"
|
||||
import path from "path"
|
||||
import { displaySlice, promptOffsetWidth } from "./display"
|
||||
import { parseSlashHead } from "./parse"
|
||||
|
||||
export async function directoryAutocomplete(
|
||||
file: Pick<OpenCodeClient["file"], "list">,
|
||||
location: { directory: string; workspace?: string },
|
||||
query: string,
|
||||
home: string,
|
||||
) {
|
||||
const search = directoryAutocompleteSearch(query, location.directory, home)
|
||||
const result = await file.list({ location, path: search.directory })
|
||||
const exact = directoryAutocompleteExactValue(query, search)
|
||||
return [
|
||||
...(exact ? [{ value: exact, absolute: search.directory }] : []),
|
||||
...result.data
|
||||
.filter((item) => item.type === "directory")
|
||||
.map((item) => path.resolve(result.location.directory, item.path))
|
||||
.filter((absolute) => directoryAutocompleteMatches(path.basename(absolute), search.query))
|
||||
.map((absolute) => ({
|
||||
value: directoryAutocompleteResultValue(path.basename(absolute) + "/", search),
|
||||
absolute,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
export function slashArgumentAutocomplete(
|
||||
value: string,
|
||||
offset: number,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||
import path from "path"
|
||||
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
|
||||
import {
|
||||
directoryAutocomplete,
|
||||
directoryAutocompleteExactValue,
|
||||
directoryAutocompleteMatches,
|
||||
directoryAutocompleteResultValue,
|
||||
|
|
@ -10,6 +11,64 @@ import {
|
|||
slashArgumentAutocomplete,
|
||||
} from "../../src/prompt/directory-completion"
|
||||
|
||||
describe("directoryAutocomplete", () => {
|
||||
test("lists parents and siblings through the current workspace without changing location", async () => {
|
||||
const location = { directory: "/project/current", workspace: "workspace_1" }
|
||||
const calls: unknown[] = []
|
||||
const file = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: location.directory, workspaceID: location.workspace },
|
||||
data: [
|
||||
{ path: "./", type: "directory" },
|
||||
{ path: "../sibling/", type: "directory" },
|
||||
{ path: "../.hidden/", type: "directory" },
|
||||
{ path: "../README.md", type: "file" },
|
||||
],
|
||||
}
|
||||
},
|
||||
} as Parameters<typeof directoryAutocomplete>[0]
|
||||
expect(await directoryAutocomplete(file, location, "..", "/home/user")).toEqual([
|
||||
{ value: "..", absolute: path.resolve("/project") },
|
||||
{ value: "../current/", absolute: path.resolve("/project/current") },
|
||||
{ value: "../sibling/", absolute: path.resolve("/project/sibling") },
|
||||
])
|
||||
expect(await directoryAutocomplete(file, location, "../sib", "/home/user")).toEqual([
|
||||
{ value: "../sibling/", absolute: path.resolve("/project/sibling") },
|
||||
])
|
||||
expect(calls).toEqual([
|
||||
{ location, path: path.resolve("/project") },
|
||||
{ location, path: path.resolve("/project") },
|
||||
])
|
||||
})
|
||||
|
||||
test("resolves home and nested sibling completions against the current location", async () => {
|
||||
const location = { directory: "/project/current", workspace: "workspace_1" }
|
||||
const calls: unknown[] = []
|
||||
const file = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: location.directory },
|
||||
data: [{ path: "../sibling/src/", type: "directory" }],
|
||||
}
|
||||
},
|
||||
} as Parameters<typeof directoryAutocomplete>[0]
|
||||
expect(await directoryAutocomplete(file, location, "../sibling/s", "/project/sibling")).toEqual([
|
||||
{ value: "../sibling/src/", absolute: path.resolve("/project/sibling/src") },
|
||||
])
|
||||
expect(await directoryAutocomplete(file, location, "~", "/project/sibling")).toEqual([
|
||||
{ value: "~", absolute: "/project/sibling" },
|
||||
{ value: "~/src/", absolute: path.resolve("/project/sibling/src") },
|
||||
])
|
||||
expect(calls).toEqual([
|
||||
{ location, path: path.resolve("/project/sibling") },
|
||||
{ location, path: "/project/sibling" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
const commands = [
|
||||
{
|
||||
id: "session.cd",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue