Compare commits

...

8 Commits

Author SHA1 Message Date
James Long 4f08d14c98 refactor(tui): clarify directory completion module 2026-08-12 00:07:25 +00:00
James Long 7833f92514 refactor(tui): keep cd completion metadata local 2026-08-12 00:04:53 +00:00
James Long d0f75776ca fix(tui): align recents with autocomplete results 2026-08-11 23:59:28 +00:00
James Long 527aa6fa4d fix(tui): synchronize recent directory options 2026-08-11 23:52:17 +00:00
James Long edc667c650 fix(tui): preserve autocomplete results while loading 2026-08-11 23:49:59 +00:00
James Long 021dc17ad7 fix(tui): hide dot directories in cd completion 2026-08-11 23:44:24 +00:00
James Long 1ff43c8d8a refactor(tui): list cd directories 2026-08-11 23:40:47 +00:00
James Long 56a70de106 feat(tui): autocomplete cd directories 2026-08-11 23:14:33 +00:00
5 changed files with 555 additions and 80 deletions
@@ -18,17 +18,24 @@ import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale"
import type { PromptInfo, PromptPartRef } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency"
import { Keymap } from "../../context/keymap"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { displayCharAt, mentionTriggerIndex, slashTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/client"
import { Skill } from "@opencode-ai/schema/skill"
import { stringWidth } from "../../util/string-width"
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
import { moveSelection, revealSelectionOffset } from "../../ui/select-controller"
import {
directoryAutocompleteExactValue,
directoryAutocompleteMatches,
directoryAutocompleteResultValue,
directoryAutocompleteSearch,
slashArgumentAutocomplete,
} from "../../prompt/directory-completion"
export type AutocompleteRef = {
onInput: (value: string) => void
visible: false | "@" | "/"
visible: false | "@" | "/" | "directory"
}
export type AutocompleteOption = {
@@ -40,12 +47,24 @@ export type AutocompleteOption = {
isDirectory?: boolean
onSelect?: () => void
path?: string
absolute?: string
destructive?: { id: string; confirm: string; run: () => void }
kind?: "skill"
}
type AutocompleteResults = {
options: AutocompleteOption[]
failed: boolean
mode: AutocompleteRef["visible"]
query: string
resolved: boolean
}
export function Autocomplete(props: {
value: string
sessionID?: string
argumentAutocomplete?: (command: KeymapCommand) => "directory" | undefined
directoryOptions?: (query: string) => AutocompleteOption[]
setPrompt: (input: (prompt: PromptInfo) => void) => void
setExtmark: (part: PromptPartRef, extmarkId: number) => void
anchor: () => BoxRenderable
@@ -76,6 +95,8 @@ export function Autocomplete(props: {
})
const [positionTick, setPositionTick] = createSignal(0)
const [dismissedValue, setDismissedValue] = createSignal<string>()
const [confirming, setConfirming] = createSignal<string>()
createEffect(() => {
if (!store.visible) return
@@ -119,7 +140,9 @@ export function Autocomplete(props: {
// Track props.value to make memo reactive to text changes
props.value // <- there surely is a better way to do this, like making .input() reactive
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
return props
.input()
.getTextRange(store.visible === "directory" ? store.index : store.index + 1, props.input().cursorOffset)
})
// filter() reads reactive props.value plus non-reactive cursor/text state.
@@ -266,7 +289,7 @@ export function Autocomplete(props: {
const references = createMemo(() => data.location.reference.list() ?? [])
const referenceMatch = createMemo(() => {
if (!store.visible || store.visible === "/") return
if (store.visible !== "@") return
const base = parseFileLineRange(search()).base
const slash = base.indexOf("/")
const alias = slash === -1 ? base : base.slice(0, slash)
@@ -299,36 +322,82 @@ export function Autocomplete(props: {
insertPart(filename, part)
}
function insertDirectory(directory: string) {
const input = props.input()
const cursorOffset = input.cursorOffset
input.cursorOffset = store.index
const start = input.logicalCursor
input.cursorOffset = cursorOffset
const end = input.logicalCursor
input.deleteRange(start.row, start.col, end.row, end.col)
input.insertText(directory)
}
const [files] = createResource(
() => ({ query: search(), location: location.current, visible: store.visible }),
async (input) => {
if (!input.visible || input.visible === "/") return { options: [], failed: false }
if (referenceMatch()) return { options: [], failed: false }
async (input, info): Promise<AutocompleteResults> => {
if (!input.visible || input.visible === "/")
return { options: [], failed: false, mode: input.visible, query: input.query, resolved: true }
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 result = await client.api.file
.find({
query: base,
limit: 20,
location: {
directory: input.location?.directory,
workspace: input.location?.workspaceID ?? data.location.default().workspaceID,
},
})
.then(
(result) => result,
() => undefined,
)
const requestLocation = {
directory: directorySearch?.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(
(result) => result,
() => undefined,
)
if (!result) return { options: [], failed: true }
if (!result)
return info.value?.mode === input.visible
? { ...info.value, failed: true }
: { options: [], failed: true, mode: input.visible, query: input.query, resolved: false }
const options: AutocompleteOption[] = []
// Add file options. Trust the order returned by fff (frecency, fuzzy
// score, filename bonus, etc. are already factored in).
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(
...result.data.map((item): AutocompleteOption => {
...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),
}
}
const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
return {
display: Locale.truncateMiddle(filename, width),
@@ -342,15 +411,27 @@ export function Autocomplete(props: {
}),
)
return { options, failed: false }
return { options, failed: false, mode: input.visible, query: input.query, resolved: true }
},
{
initialValue: { options: [], failed: false },
initialValue: {
options: [],
failed: false,
mode: false as AutocompleteRef["visible"],
query: "",
resolved: false,
},
},
)
const visibleFiles = createMemo(() => {
const value = files.loading ? files.latest : files()
if (value?.mode === store.visible) return value
return { options: [], failed: false, query: "", resolved: false }
})
const mcpResources = createMemo(() => {
if (!store.visible || store.visible === "/") return []
if (store.visible !== "@") return []
const options: AutocompleteOption[] = []
const width = props.anchor().width - 4
@@ -474,14 +555,34 @@ export function Autocomplete(props: {
}))
})
const supplementalDirectoryOptions = createMemo((): AutocompleteOption[] => {
const results = visibleFiles()
if (store.visible !== "directory" || !results.resolved) return []
const width = props.anchor().width - 4
return (props.directoryOptions?.(results.query) ?? []).map((item) => {
const value = item.value
return {
...item,
display: Locale.truncateMiddle(item.display, width),
onSelect: item.onSelect ?? (value ? () => insertDirectory(value) : undefined),
}
})
})
const options = createMemo(() => {
const fileSearch = files()
const fileSearch = visibleFiles()
const referenceMatchValue = referenceMatch()
const agentsValue = agents()
const referenceAliasesValue = referenceAliases()
const commandsValue = commands()
const searchValue = search()
if (store.visible === "directory") {
const supplemental = supplementalDirectoryOptions()
const paths = new Set(supplemental.map((item) => item.absolute))
return [...supplemental, ...fileSearch.options.filter((item) => !paths.has(item.absolute))]
}
if (store.visible === "@" && referenceMatchValue) {
return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
}
@@ -528,6 +629,7 @@ export function Autocomplete(props: {
createEffect(() => {
filter()
setStore("selected", 0)
setConfirming(undefined)
})
function move(direction: -1 | 1) {
@@ -537,6 +639,7 @@ export function Autocomplete(props: {
}
function moveTo(next: number) {
if (next !== store.selected) setConfirming(undefined)
setStore("selected", next)
if (!scroll) return
const offset = revealSelectionOffset(scroll.scrollTop, {
@@ -551,8 +654,26 @@ export function Autocomplete(props: {
function select() {
const selected = options()[store.selected]
if (!selected) return
hide(true)
if (store.visible !== "directory") {
hide(true)
selected.onSelect?.()
return
}
selected.onSelect?.()
setDismissedValue(props.input().plainText)
hide(true)
}
function triggerDestructive() {
const action = options()[store.selected]?.destructive
if (!action) return false
if (confirming() !== action.id) {
setConfirming(action.id)
return
}
action.run()
setStore("selected", Math.max(0, Math.min(store.selected, options().length - 2)))
setConfirming(undefined)
}
function expandDirectory() {
@@ -563,7 +684,13 @@ export function Autocomplete(props: {
const currentCursorOffset = input.cursorOffset
const displayText = (selected.value ?? selected.display).trimEnd()
const path = displayText.startsWith("@") ? displayText.slice(1) : displayText
const selectedPath = displayText.startsWith("@") ? displayText.slice(1) : displayText
if (store.visible === "directory") {
insertDirectory(selectedPath.endsWith(path.sep) ? selectedPath : selectedPath + path.sep)
setStore("selected", 0)
return
}
input.cursorOffset = store.index
const startCursor = input.logicalCursor
@@ -571,7 +698,7 @@ export function Autocomplete(props: {
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText("@" + path + "/")
input.insertText("@" + selectedPath + "/")
setStore("selected", 0)
}
@@ -629,13 +756,20 @@ export function Autocomplete(props: {
select()
},
},
{
id: "prompt.autocomplete.destructive",
title: "Confirm autocomplete action",
group: "Autocomplete",
bind: "ctrl+d",
run: triggerDestructive,
},
],
}))
function show(mode: "@" | "/") {
function show(mode: Exclude<AutocompleteRef["visible"], false>, index = props.input().cursorOffset) {
setStore({
visible: mode,
index: props.input().cursorOffset,
index,
})
}
@@ -653,6 +787,7 @@ export function Autocomplete(props: {
draft.text = input.plainText
})
}
setConfirming(undefined)
setStore("visible", false)
}
@@ -670,6 +805,15 @@ export function Autocomplete(props: {
return store.visible
},
onInput(value) {
if (dismissedValue() === value) return
setDismissedValue(undefined)
const offset = props.input().cursorOffset
const argument = slashArgumentAutocomplete(value, offset, keymapCommands(), props.argumentAutocomplete)
if (argument?.type === "directory") {
show("directory", argument.index)
return
}
if (store.visible) {
if (
// Typed text before the trigger
@@ -683,7 +827,6 @@ export function Autocomplete(props: {
}
// Check if autocomplete should reopen (e.g., after backspace deleted a space)
const offset = props.input().cursorOffset
if (offset === 0) return
const slash = slashTriggerIndex(value, offset)
@@ -713,12 +856,18 @@ export function Autocomplete(props: {
let scroll: ScrollBoxRenderable
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const emptyMessage = createMemo(() => {
const fileSearch = visibleFiles()
if (store.visible === "/") return "No matching commands"
if (store.visible === "directory") {
if (files.loading) return "Searching…"
if (fileSearch.failed) return "Could not search directories. Keep typing to try again."
return "No matching directories"
}
if (files.loading) return "Searching…"
if (files().failed) return "Could not search files. Keep typing to try again."
if (fileSearch.failed) return "Could not search files. Keep typing to try again."
return "No matching files, agents, or references"
})
const emptyError = createMemo(() => store.visible === "@" && !files.loading && files().failed)
const emptyError = createMemo(() => store.visible === "@" && !files.loading && visibleFiles().failed)
return (
<box
@@ -746,41 +895,60 @@ export function Autocomplete(props: {
</box>
}
>
{(option, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index === store.selected ? theme.background.action.primary.focused : undefined}
flexDirection="row"
onMouseMove={() => {
setStore("input", "mouse")
}}
onMouseOver={() => {
if (store.input !== "mouse") return
moveTo(index)
}}
onMouseDown={() => {
setStore("input", "mouse")
moveTo(index)
}}
onMouseUp={() => select()}
>
<text
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.default}
flexShrink={0}
{(option, index) => {
const destructive = () => option().destructive
const confirmingAction = () => {
const action = destructive()
return action !== undefined && action.id === confirming()
}
return (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={
confirmingAction()
? theme.background.action.destructive.focused
: index === store.selected
? theme.background.action.primary.focused
: undefined
}
flexDirection="row"
onMouseMove={() => {
setStore("input", "mouse")
}}
onMouseOver={() => {
if (store.input !== "mouse") return
moveTo(index)
}}
onMouseDown={() => {
setStore("input", "mouse")
moveTo(index)
}}
onMouseUp={() => select()}
>
{option().display}
</text>
<Show when={option().description}>
<text
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
fg={
confirmingAction()
? theme.text.action.destructive.focused
: index === store.selected
? theme.text.action.primary.focused
: theme.text.default
}
flexShrink={0}
>
{" " + option().description?.trimStart()}
{confirmingAction() ? destructive()?.confirm : option().display}
</text>
</Show>
</box>
)}
<Show when={!confirmingAction() && option().description}>
<text
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
>
{" " + option().description?.trimStart()}
</text>
</Show>
</box>
)
}}
</Index>
</scrollbox>
</box>
+47 -13
View File
@@ -33,7 +33,7 @@ import { computePromptTraits } from "../../prompt/traits"
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
import { usePromptStash } from "../../prompt/stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { type AutocompleteOption, type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error"
@@ -66,6 +66,8 @@ import {
promptAttachmentLabel,
} from "../../prompt/attachment"
import { DialogImagePreview } from "../dialog-image-preview"
import { useDirectoryRecents } from "../../prompt/directory-recents"
import { directoryRecentValue } from "../../prompt/directory-completion"
export type PromptProps = {
sessionID?: string
@@ -159,6 +161,7 @@ export function Prompt(props: PromptProps) {
const editor = useEditorContext()
const route = useRoute()
const data = useData()
const directoryRecents = useDirectoryRecents()
const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation()
const config = useConfig().data
@@ -227,27 +230,34 @@ export function Prompt(props: PromptProps) {
return
}
const sessionID = props.sessionID
const session = sessionID ? data.session.get(sessionID) : undefined
const sourceProjectID = session?.projectID ?? data.location.info()?.project.id
const value = input.trim()
const expanded =
value === "~" ? paths.home : value.startsWith("~/") ? path.join(paths.home, value.slice(2)) : value
const directory = path.resolve(
session?.location.directory ?? currentLocation.current?.directory ?? data.location.default().directory,
expanded,
)
if (!sessionID) {
const value = input.trim()
const expanded =
value === "~" ? paths.home : value.startsWith("~/") ? path.join(paths.home, value.slice(2)) : value
const directory = path.resolve(
currentLocation.current?.directory ?? data.location.default().directory,
expanded,
)
const location = await client.api.location.get({ location: { directory } }).catch((error) => {
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" })
return undefined
})
if (!location) return
if (sourceProjectID) directoryRecents.touch(sourceProjectID, location.directory)
currentLocation.set(location)
return
}
await client.api.session
.move({ sessionID, directory: input })
.catch((error) =>
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" }),
)
const error = await client.api.session.move({ sessionID, directory: input }).then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" })
return
}
if (sourceProjectID) directoryRecents.touch(sourceProjectID, directory)
},
},
],
@@ -1855,6 +1865,30 @@ export function Prompt(props: PromptProps) {
</box>
<Autocomplete
sessionID={props.sessionID}
argumentAutocomplete={(command) => (command.id === "session.cd" ? "directory" : undefined)}
directoryOptions={(query): AutocompleteOption[] => {
if (query !== "") return []
const projectID =
(props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ??
data.location.info()?.project.id
if (!projectID) return []
return directoryRecents.list(projectID).map((item) => {
const value = directoryRecentValue(item.directory, paths.home)
return {
display: value,
value,
description: "recent",
isDirectory: true,
path: value,
absolute: item.directory,
destructive: {
id: item.directory,
confirm: "Press ctrl+d to confirm",
run: () => directoryRecents.remove(projectID, item.directory),
},
}
})
}}
ref={(r) => {
setAuto(() => r)
}}
@@ -0,0 +1,75 @@
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
import path from "path"
import { displaySlice, promptOffsetWidth } from "./display"
import { parseSlashHead } from "./parse"
export function slashArgumentAutocomplete(
value: string,
offset: number,
commands: readonly KeymapCommand[],
autocomplete: ((command: KeymapCommand) => "directory" | undefined) | undefined,
) {
const beforeCursor = displaySlice(value, 0, offset)
const head = parseSlashHead(beforeCursor, /\s/)
if (!head || head.end === beforeCursor.length) return
const command = commands.find(
(command) =>
command.slash?.arguments &&
(command.slash.name === head.name || command.slash.aliases?.includes(head.name) === true),
)
if (!command) return
const type = autocomplete?.(command)
if (!type) return
return {
type,
index: promptOffsetWidth(beforeCursor.slice(0, head.end + 1)),
}
}
export function directoryAutocompleteSearch(query: string, directory: string, home: string) {
if (query === "~") return { directory: home, prefix: "~/", query: "" }
if (query.startsWith("~/")) return directorySearch(query.slice(2), home, "~/")
if (/^(?:\.\.\/)*\.\.$/.test(query))
return { directory: path.resolve(directory, query), prefix: query + "/", query: "" }
if (query.startsWith("/")) return directorySearch(query.slice(1), path.parse(directory).root, "/")
return directorySearch(query, directory, "")
}
function directorySearch(query: string, root: string, prefix: string) {
const separator = query.lastIndexOf("/")
if (separator === -1) return { directory: root, prefix, query }
const parent = query.slice(0, separator + 1)
return {
directory: path.resolve(root, parent),
prefix: prefix + parent,
query: query.slice(separator + 1),
}
}
export function directoryAutocompleteResultValue(
directory: string,
search: ReturnType<typeof directoryAutocompleteSearch>,
) {
return (search.prefix || "./") + directory.replace(/^[\\/]+/, "")
}
export function directoryAutocompleteExactValue(value: string, search: ReturnType<typeof directoryAutocompleteSearch>) {
if (!value || !search.prefix || search.query) return
return value
}
export function directoryAutocompleteMatches(directory: string, query: string) {
const value = directory.replace(/^[\\/]+/, "")
if (!query && value.startsWith(".")) return false
return value.toLowerCase().startsWith(query.toLowerCase())
}
export function directoryRecentValue(directory: string, home: string) {
const relative = path.relative(home, directory)
if (!relative) return "~"
if (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))
return "~/" + relative.split(path.sep).join("/")
return directory
}
@@ -0,0 +1,36 @@
import { useStorage } from "../context/storage"
type RecentDirectory = {
directory: string
usedAt: number
}
type PersistedState = {
projects: Record<string, RecentDirectory[]>
}
export function useDirectoryRecents() {
const [store, updateStore] = useStorage().store<PersistedState>("directory-recents", {
initial: { projects: {} },
key: "directory",
})
return {
list(projectID: string) {
return (store.projects[projectID] ?? []).toSorted((a, b) => b.usedAt - a.usedAt)
},
touch(projectID: string, directory: string) {
void updateStore((draft) => {
draft.projects[projectID] = [
{ directory, usedAt: Date.now() },
...(draft.projects[projectID] ?? []).filter((item) => item.directory !== directory),
].slice(0, 10)
}).catch((error) => console.error("Failed to persist directory recents", error))
},
remove(projectID: string, directory: string) {
void updateStore((draft) => {
draft.projects[projectID] = (draft.projects[projectID] ?? []).filter((item) => item.directory !== directory)
}).catch((error) => console.error("Failed to remove directory recent", error))
},
}
}
@@ -0,0 +1,162 @@
import { describe, expect, test } from "bun:test"
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
import {
directoryAutocompleteExactValue,
directoryAutocompleteMatches,
directoryAutocompleteResultValue,
directoryAutocompleteSearch,
directoryRecentValue,
slashArgumentAutocomplete,
} from "../../src/prompt/directory-completion"
const commands = [
{
id: "session.cd",
slash: { name: "cd", aliases: ["chdir"], arguments: true },
run: () => undefined,
},
] satisfies KeymapCommand[]
const argumentAutocomplete = (command: KeymapCommand) =>
command.id === "session.cd" ? ("directory" as const) : undefined
describe("slashArgumentAutocomplete", () => {
test("starts after the command separator", () => {
expect(slashArgumentAutocomplete("/cd ", 4, commands, argumentAutocomplete)).toEqual({
type: "directory",
index: 4,
})
expect(slashArgumentAutocomplete("/cd src", 7, commands, argumentAutocomplete)).toEqual({
type: "directory",
index: 4,
})
})
test("supports aliases", () => {
expect(slashArgumentAutocomplete("/chdir src", 10, commands, argumentAutocomplete)).toEqual({
type: "directory",
index: 7,
})
})
test("does not complete the command token", () => {
expect(slashArgumentAutocomplete("/cd", 3, commands, argumentAutocomplete)).toBeUndefined()
expect(slashArgumentAutocomplete("/other ", 7, commands, argumentAutocomplete)).toBeUndefined()
})
})
describe("directoryAutocompleteSearch", () => {
test("searches from home after a home prefix", () => {
expect(directoryAutocompleteSearch("~", "/project", "/home/user")).toEqual({
directory: "/home/user",
prefix: "~/",
query: "",
})
expect(directoryAutocompleteSearch("~/pro", "/project", "/home/user")).toEqual({
directory: "/home/user",
prefix: "~/",
query: "pro",
})
expect(directoryAutocompleteSearch("~/projects/open", "/project", "/home/user")).toEqual({
directory: "/home/user/projects",
prefix: "~/projects/",
query: "open",
})
})
test("searches from parent prefixes", () => {
expect(directoryAutocompleteSearch("..", "/project/src", "/home/user")).toEqual({
directory: "/project",
prefix: "../",
query: "",
})
expect(directoryAutocompleteSearch("../../pac", "/project/src/lib", "/home/user")).toEqual({
directory: "/project",
prefix: "../../",
query: "pac",
})
expect(directoryAutocompleteSearch("../../..", "/project/src/lib", "/home/user")).toEqual({
directory: "/",
prefix: "../../../",
query: "",
})
})
test("keeps ordinary searches rooted at the current directory", () => {
expect(directoryAutocompleteSearch("src", "/project", "/home/user")).toEqual({
directory: "/project",
prefix: "",
query: "src",
})
expect(directoryAutocompleteSearch("packages/core", "/project", "/home/user")).toEqual({
directory: "/project/packages",
prefix: "packages/",
query: "core",
})
expect(directoryAutocompleteSearch("/root/pro", "/project", "/home/user")).toEqual({
directory: "/root",
prefix: "/root/",
query: "pro",
})
})
})
describe("directoryAutocompleteResultValue", () => {
test("marks current-directory results as relative", () => {
const search = directoryAutocompleteSearch("", "/project", "/home/user")
expect(directoryAutocompleteResultValue("src/", search)).toBe("./src/")
expect(directoryAutocompleteResultValue("/src/", search)).toBe("./src/")
expect(directoryAutocompleteResultValue("/", search)).toBe("./")
})
test("preserves explicit roots", () => {
expect(
directoryAutocompleteResultValue("projects/", directoryAutocompleteSearch("~/", "/project", "/home/user")),
).toBe("~/projects/")
expect(
directoryAutocompleteResultValue("src/", directoryAutocompleteSearch("../", "/project/pkg", "/home/user")),
).toBe("../src/")
})
})
describe("directoryAutocompleteExactValue", () => {
test("includes complete explicit roots", () => {
expect(
directoryAutocompleteExactValue("../..", directoryAutocompleteSearch("../..", "/project/pkg", "/home/user")),
).toBe("../..")
expect(directoryAutocompleteExactValue("~", directoryAutocompleteSearch("~", "/project", "/home/user"))).toBe("~")
})
test("omits incomplete and implicit roots", () => {
expect(
directoryAutocompleteExactValue("../../src", directoryAutocompleteSearch("../../src", "/project", "/home/user")),
).toBeUndefined()
expect(
directoryAutocompleteExactValue("", directoryAutocompleteSearch("", "/project", "/home/user")),
).toBeUndefined()
})
})
describe("directoryAutocompleteMatches", () => {
test("hides dot directories for an empty component", () => {
expect(directoryAutocompleteMatches("src/", "")).toBe(true)
expect(directoryAutocompleteMatches(".git/", "")).toBe(false)
})
test("shows dot directories when explicitly filtered", () => {
expect(directoryAutocompleteMatches(".git/", ".")).toBe(true)
expect(directoryAutocompleteMatches(".github/", ".gi")).toBe(true)
expect(directoryAutocompleteMatches(".zed/", ".gi")).toBe(false)
})
})
describe("directoryRecentValue", () => {
test("abbreviates home paths", () => {
expect(directoryRecentValue("/home/user", "/home/user")).toBe("~")
expect(directoryRecentValue("/home/user/projects/opencode", "/home/user")).toBe("~/projects/opencode")
})
test("keeps paths outside home absolute", () => {
expect(directoryRecentValue("/project/recent", "/home/user")).toBe("/project/recent")
})
})