diff options
| author | Adam <[email protected]> | 2026-01-01 08:48:35 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-01 21:03:03 -0600 |
| commit | 78940d5b7ee2f3e5020f87b400db1785b37a7d71 (patch) | |
| tree | 5784212a5a219f9c648b2e3afc6c952ea1a2da46 /packages/app/src/context | |
| parent | b84a1f714bf0b81efdf89a0dd6e35fa2b3e8692a (diff) | |
| download | opencode-78940d5b7ee2f3e5020f87b400db1785b37a7d71.tar.gz opencode-78940d5b7ee2f3e5020f87b400db1785b37a7d71.zip | |
wip(app): file context
Diffstat (limited to 'packages/app/src/context')
| -rw-r--r-- | packages/app/src/context/file.tsx | 282 | ||||
| -rw-r--r-- | packages/app/src/context/prompt.tsx | 64 |
2 files changed, 340 insertions, 6 deletions
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx new file mode 100644 index 000000000..a26f97c2a --- /dev/null +++ b/packages/app/src/context/file.tsx @@ -0,0 +1,282 @@ +import { createMemo, onCleanup } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import type { FileContent } from "@opencode-ai/sdk/v2" +import { showToast } from "@opencode-ai/ui/toast" +import { useParams } from "@solidjs/router" +import { getFilename } from "@opencode-ai/util/path" +import { useSDK } from "./sdk" +import { useSync } from "./sync" +import { persisted } from "@/utils/persist" + +export type FileSelection = { + startLine: number + startChar: number + endLine: number + endChar: number +} + +export type SelectedLineRange = { + start: number + end: number + side?: "additions" | "deletions" + endSide?: "additions" | "deletions" +} + +export type FileViewState = { + scrollTop?: number + scrollLeft?: number + selectedLines?: SelectedLineRange | null +} + +export type FileState = { + path: string + name: string + loaded?: boolean + loading?: boolean + error?: string + content?: FileContent +} + +function stripFileProtocol(input: string) { + if (!input.startsWith("file://")) return input + return input.slice("file://".length) +} + +function stripQueryAndHash(input: string) { + const hashIndex = input.indexOf("#") + const queryIndex = input.indexOf("?") + + if (hashIndex !== -1 && queryIndex !== -1) { + return input.slice(0, Math.min(hashIndex, queryIndex)) + } + + if (hashIndex !== -1) return input.slice(0, hashIndex) + if (queryIndex !== -1) return input.slice(0, queryIndex) + return input +} + +export function selectionFromLines(range: SelectedLineRange): FileSelection { + const startLine = Math.min(range.start, range.end) + const endLine = Math.max(range.start, range.end) + return { + startLine, + endLine, + startChar: 0, + endChar: 0, + } +} + +function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { + if (range.start <= range.end) return range + + const startSide = range.side + const endSide = range.endSide ?? startSide + + return { + ...range, + start: range.end, + end: range.start, + side: endSide, + endSide: startSide !== endSide ? startSide : undefined, + } +} + +export const { use: useFile, provider: FileProvider } = createSimpleContext({ + name: "File", + init: () => { + const sdk = useSDK() + const sync = useSync() + const params = useParams() + + const directory = createMemo(() => sync.data.path.directory) + + function normalize(input: string) { + const root = directory() + const prefix = root.endsWith("/") ? root : root + "/" + + let path = stripQueryAndHash(stripFileProtocol(input)) + + if (path.startsWith(prefix)) { + path = path.slice(prefix.length) + } + + if (path.startsWith(root)) { + path = path.slice(root.length) + } + + if (path.startsWith("./")) { + path = path.slice(2) + } + + if (path.startsWith("/")) { + path = path.slice(1) + } + + return path + } + + function tab(input: string) { + const path = normalize(input) + return `file://${path}` + } + + function pathFromTab(tabValue: string) { + if (!tabValue.startsWith("file://")) return + return normalize(tabValue) + } + + const inflight = new Map<string, Promise<void>>() + + const [store, setStore] = createStore<{ + file: Record<string, FileState> + }>({ + file: {}, + }) + + const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`) + + const [view, setView, _, ready] = persisted( + viewKey(), + createStore<{ + file: Record<string, FileViewState> + }>({ + file: {}, + }), + ) + + function ensure(path: string) { + if (!path) return + if (store.file[path]) return + setStore("file", path, { path, name: getFilename(path) }) + } + + function load(input: string, options?: { force?: boolean }) { + const path = normalize(input) + if (!path) return Promise.resolve() + + ensure(path) + + const current = store.file[path] + if (!options?.force && current?.loaded) return Promise.resolve() + + const pending = inflight.get(path) + if (pending) return pending + + setStore( + "file", + path, + produce((draft) => { + draft.loading = true + draft.error = undefined + }), + ) + + const promise = sdk.client.file + .read({ path }) + .then((x) => { + setStore( + "file", + path, + produce((draft) => { + draft.loaded = true + draft.loading = false + draft.content = x.data + }), + ) + }) + .catch((e) => { + setStore( + "file", + path, + produce((draft) => { + draft.loading = false + draft.error = e.message + }), + ) + showToast({ + variant: "error", + title: "Failed to load file", + description: e.message, + }) + }) + .finally(() => { + inflight.delete(path) + }) + + inflight.set(path, promise) + return promise + } + + const stop = sdk.event.listen((e) => { + const event = e.details + if (event.type !== "file.watcher.updated") return + const path = normalize(event.properties.file) + if (!path) return + if (path.startsWith(".git/")) return + if (!store.file[path]) return + load(path, { force: true }) + }) + + const get = (input: string) => store.file[normalize(input)] + + const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop + const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft + const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines + + const setScrollTop = (input: string, top: number) => { + const path = normalize(input) + setView("file", path, (current) => { + if (current?.scrollTop === top) return current + return { + ...(current ?? {}), + scrollTop: top, + } + }) + } + + const setScrollLeft = (input: string, left: number) => { + const path = normalize(input) + setView("file", path, (current) => { + if (current?.scrollLeft === left) return current + return { + ...(current ?? {}), + scrollLeft: left, + } + }) + } + + const setSelectedLines = (input: string, range: SelectedLineRange | null) => { + const path = normalize(input) + const next = range ? normalizeSelectedLines(range) : null + setView("file", path, (current) => { + if (current?.selectedLines === next) return current + return { + ...(current ?? {}), + selectedLines: next, + } + }) + } + + onCleanup(() => stop()) + + return { + ready, + normalize, + tab, + pathFromTab, + get, + load, + scrollTop, + scrollLeft, + setScrollTop, + setScrollLeft, + selectedLines, + setSelectedLines, + searchFiles: (query: string) => + sdk.client.find.files({ query, dirs: "false" }).then((x) => (x.data ?? []).map(normalize)), + searchFilesAndDirectories: (query: string) => + sdk.client.find.files({ query, dirs: "true" }).then((x) => (x.data ?? []).map(normalize)), + } + }, +}) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 25d8146ea..f77f62e3c 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -2,7 +2,7 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { batch, createMemo } from "solid-js" import { useParams } from "@solidjs/router" -import { TextSelection } from "./local" +import type { FileSelection } from "@/context/file" import { persisted } from "@/utils/persist" interface PartBase { @@ -18,7 +18,7 @@ export interface TextPart extends PartBase { export interface FileAttachmentPart extends PartBase { type: "file" path: string - selection?: TextSelection + selection?: FileSelection } export interface AgentPart extends PartBase { @@ -37,8 +37,24 @@ export interface ImageAttachmentPart { export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart export type Prompt = ContentPart[] +export type FileContextItem = { + type: "file" + path: string + selection?: FileSelection +} + +export type ContextItem = FileContextItem + export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] +function isSelectionEqual(a?: FileSelection, b?: FileSelection) { + if (!a && !b) return true + if (!a || !b) return false + return ( + a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar + ) +} + export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { if (promptA.length !== promptB.length) return false for (let i = 0; i < promptA.length; i++) { @@ -48,8 +64,11 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { if (partA.type === "text" && partA.content !== (partB as TextPart).content) { return false } - if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { - return false + if (partA.type === "file") { + const fileA = partA as FileAttachmentPart + const fileB = partB as FileAttachmentPart + if (fileA.path !== fileB.path) return false + if (!isSelectionEqual(fileA.selection, fileB.selection)) return false } if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) { return false @@ -61,7 +80,7 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { return true } -function cloneSelection(selection?: TextSelection) { +function cloneSelection(selection?: FileSelection) { if (!selection) return undefined return { ...selection } } @@ -84,24 +103,57 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( name: "Prompt", init: () => { const params = useParams() - const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`) + const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`) const [store, setStore, _, ready] = persisted( name(), createStore<{ prompt: Prompt cursor?: number + context: { + activeTab: boolean + items: (ContextItem & { key: string })[] + } }>({ prompt: clonePrompt(DEFAULT_PROMPT), cursor: undefined, + context: { + activeTab: true, + items: [], + }, }), ) + function keyForItem(item: ContextItem) { + if (item.type !== "file") return item.type + const start = item.selection?.startLine + const end = item.selection?.endLine + return `${item.type}:${item.path}:${start}:${end}` + } + return { ready, current: createMemo(() => store.prompt), cursor: createMemo(() => store.cursor), dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + context: { + activeTab: createMemo(() => store.context.activeTab), + items: createMemo(() => store.context.items), + addActive() { + setStore("context", "activeTab", true) + }, + removeActive() { + setStore("context", "activeTab", false) + }, + add(item: ContextItem) { + const key = keyForItem(item) + if (store.context.items.find((x) => x.key === key)) return + setStore("context", "items", (items) => [...items, { key, ...item }]) + }, + remove(key: string) { + setStore("context", "items", (items) => items.filter((x) => x.key !== key)) + }, + }, set(prompt: Prompt, cursorPosition?: number) { const next = clonePrompt(prompt) batch(() => { |
