fix(tui): scope file autocomplete to session (#33458)

This commit is contained in:
Dax 2026-06-22 20:31:36 -04:00 committed by GitHub
parent 975b1132f1
commit ef2357915e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 70 additions and 49 deletions

View File

@ -65,7 +65,7 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 timeout-minutes: 20
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none run: GITHUB_ACTIONS=false bun turbo test
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}

View File

@ -35,6 +35,7 @@ import { SDKProvider, useSDK } from "./context/sdk"
import { StartupLoading } from "./component/startup-loading" import { StartupLoading } from "./component/startup-loading"
import { SyncProvider, useSync } from "./context/sync" import { SyncProvider, useSync } from "./context/sync"
import { DataProvider } from "./context/data" import { DataProvider } from "./context/data"
import { LocationProvider } from "./context/location"
import { LocalProvider, useLocal } from "./context/local" import { LocalProvider, useLocal } from "./context/local"
import { DialogModel } from "./component/dialog-model" import { DialogModel } from "./component/dialog-model"
import { useConnected } from "./component/use-connected" import { useConnected } from "./component/use-connected"
@ -303,10 +304,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptHistoryProvider> <PromptHistoryProvider>
<PromptRefProvider> <PromptRefProvider>
<EditorContextProvider> <EditorContextProvider>
<App <LocationProvider>
onSnapshot={input.onSnapshot} <App
pluginHost={input.pluginHost} onSnapshot={input.onSnapshot}
/> pluginHost={input.pluginHost}
/>
</LocationProvider>
</EditorContextProvider> </EditorContextProvider>
</PromptRefProvider> </PromptRefProvider>
</PromptHistoryProvider> </PromptHistoryProvider>

View File

@ -13,6 +13,7 @@ import { useData } from "../../context/data"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { useTuiPaths } from "../../context/runtime" import { useTuiPaths } from "../../context/runtime"
import { useTuiConfig } from "../../config" import { useTuiConfig } from "../../config"
import { useLocation } from "../../context/location"
import { useTheme, selectedForeground } from "../../context/theme" import { useTheme, selectedForeground } from "../../context/theme"
import { SplitBorder } from "../../ui/border" import { SplitBorder } from "../../ui/border"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
@ -21,6 +22,7 @@ import type { PromptInfo } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency" import { useFrecency } from "../../prompt/frecency"
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/sdk/v2"
function removeLineRange(input: string) { function removeLineRange(input: string) {
const hashIndex = input.lastIndexOf("#") const hashIndex = input.lastIndexOf("#")
@ -94,6 +96,7 @@ export function Autocomplete(props: {
const frecency = useFrecency() const frecency = useFrecency()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const paths = useTuiPaths() const paths = useTuiPaths()
const location = useLocation()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
index: 0, index: 0,
selected: 0, selected: 0,
@ -236,16 +239,18 @@ export function Autocomplete(props: {
} }
} }
function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) { function createFilePart(
const baseDir = (sync.path.directory || paths.cwd).replace(/\/+$/, "") item: FileSystemEntry,
const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item) filePath: string,
const urlObj = pathToFileURL(fullPath) lineRange?: { startLine: number; endLine?: number },
) {
const urlObj = pathToFileURL(filePath)
const filename = const filename =
lineRange && !item.endsWith("/") lineRange && item.type !== "directory"
? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` ? `${item.path}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
: item : item.path
if (lineRange && !item.endsWith("/")) { if (lineRange && item.type !== "directory") {
urlObj.searchParams.set("start", String(lineRange.startLine)) urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) { if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine)) urlObj.searchParams.set("end", String(lineRange.endLine))
@ -254,10 +259,9 @@ export function Autocomplete(props: {
return { return {
filename, filename,
url: urlObj.href,
part: { part: {
type: "file" as const, type: "file" as const,
mime: "text/plain", mime: item.mime,
filename, filename,
url: urlObj.href, url: urlObj.href,
source: { source: {
@ -267,7 +271,7 @@ export function Autocomplete(props: {
end: 0, end: 0,
value: "", value: "",
}, },
path: item, path: item.path,
}, },
}, },
} }
@ -284,7 +288,7 @@ export function Autocomplete(props: {
}) })
function normalizeMentionPath(filePath: string) { function normalizeMentionPath(filePath: string) {
const baseDir = sync.path.directory || paths.cwd const baseDir = location()?.directory || sync.path.directory || paths.cwd
const absolute = path.resolve(filePath) const absolute = path.resolve(filePath)
const relative = path.relative(baseDir, absolute) const relative = path.relative(baseDir, absolute)
@ -301,7 +305,11 @@ export function Autocomplete(props: {
startLine: input.lineStart, startLine: input.lineStart,
endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined, endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined,
} }
const { filename, part } = createFilePart(item, lineRange) const { filename, part } = createFilePart(
{ path: item, type: "file", mime: "text/plain" },
input.filePath,
lineRange,
)
const index = store.visible === "@" ? store.index : props.input().cursorOffset const index = store.visible === "@" ? store.index : props.input().cursorOffset
setStore("visible", false) setStore("visible", false)
@ -310,17 +318,20 @@ export function Autocomplete(props: {
} }
const [files] = createResource( const [files] = createResource(
() => search(), () => ({ query: search(), location: location() }),
async (query) => { async (input) => {
if (!store.visible || store.visible === "/") return [] if (!store.visible || store.visible === "/") return []
if (referenceMatch()) return [] if (referenceMatch()) return []
const { lineRange, baseQuery } = extractLineRange(query ?? "") const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
// Get files from SDK // Get files from SDK
const result = await sdk.client.v2.fs.find({ const result = await sdk.client.v2.fs.find({
query: baseQuery, query: baseQuery,
limit: "20", limit: "20",
location: { workspace: project.workspace.current() }, location: {
directory: input.location?.directory,
workspace: input.location?.workspaceID ?? project.workspace.current(),
},
}) })
const options: AutocompleteOption[] = [] const options: AutocompleteOption[] = []
@ -331,7 +342,11 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4 const width = props.anchor().width - 4
options.push( options.push(
...result.data.data.map((item): AutocompleteOption => { ...result.data.data.map((item): AutocompleteOption => {
const { filename, url, part } = createFilePart(item.path, lineRange) const { filename, part } = createFilePart(
item,
path.join(result.data.location.directory, item.path),
lineRange,
)
return { return {
display: Locale.truncateMiddle(filename, width), display: Locale.truncateMiddle(filename, width),
value: filename, value: filename,

View File

@ -0,0 +1,14 @@
import type { LocationRef } from "@opencode-ai/sdk/v2"
import { createContext, useContext, type Accessor, type ParentProps } from "solid-js"
const context = createContext<Accessor<LocationRef | undefined>>()
export function LocationProvider(props: ParentProps<{ location?: LocationRef }>) {
return <context.Provider value={() => props.location}>{props.children}</context.Provider>
}
export function useLocation() {
const value = useContext(context)
if (!value) throw new Error("Location context must be used within a LocationProvider")
return value
}

View File

@ -1,31 +1,15 @@
import path from "path" import path from "path"
import { createContext, useContext, type ParentProps } from "solid-js"
import { abbreviateHome } from "../runtime" import { abbreviateHome } from "../runtime"
import { useLocation } from "./location"
import { useTuiPaths } from "./runtime" import { useTuiPaths } from "./runtime"
const context = createContext<{
path: () => string
format: (input?: string) => string
}>()
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
const paths = useTuiPaths()
return (
<context.Provider
value={{
path: () => props.path || paths.cwd,
format: (input) => formatPath(input, props.path || paths.cwd, paths.home),
}}
>
{props.children}
</context.Provider>
)
}
export function usePathFormatter() { export function usePathFormatter() {
const value = useContext(context) const paths = useTuiPaths()
if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider") const location = useLocation()
return value return {
path: () => location()?.directory || paths.cwd,
format: (input?: string) => formatPath(input, location()?.directory || paths.cwd, paths.home),
}
} }
function formatPath(input: string | undefined, base: string, home: string) { function formatPath(input: string | undefined, base: string, home: string) {

View File

@ -80,7 +80,8 @@ import { usePluginRuntime } from "../../plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action" import { DialogRetryAction } from "../../component/dialog-retry-action"
import { getRevertDiffFiles } from "../../util/revert-diff" import { getRevertDiffFiles } from "../../util/revert-diff"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format" import { usePathFormatter } from "../../context/path-format"
import { LocationProvider } from "../../context/location"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@ -193,6 +194,10 @@ export function Session() {
const { theme } = useTheme() const { theme } = useTheme()
const promptRef = usePromptRef() const promptRef = usePromptRef()
const session = createMemo(() => sync.session.get(route.sessionID)) const session = createMemo(() => sync.session.get(route.sessionID))
const location = createMemo(() => {
const current = session()
return current ? { directory: current.directory, workspaceID: current.workspaceID } : undefined
})
createEffect(() => { createEffect(() => {
const title = Locale.truncate(session()?.title ?? "", 50) const title = Locale.truncate(session()?.title ?? "", 50)
@ -1138,7 +1143,7 @@ export function Session() {
createEffect(on(() => route.sessionID, toBottom)) createEffect(on(() => route.sessionID, toBottom))
return ( return (
<PathFormatterProvider path={session()?.directory}> <LocationProvider location={location()}>
<context.Provider <context.Provider
value={{ value={{
get width() { get width() {
@ -1338,7 +1343,7 @@ export function Session() {
</Show> </Show>
</box> </box>
</context.Provider> </context.Provider>
</PathFormatterProvider> </LocationProvider>
) )
} }