summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-01 08:48:35 -0600
committerAdam <[email protected]>2026-01-01 21:03:03 -0600
commit78940d5b7ee2f3e5020f87b400db1785b37a7d71 (patch)
tree5784212a5a219f9c648b2e3afc6c952ea1a2da46 /packages/app/src/context
parentb84a1f714bf0b81efdf89a0dd6e35fa2b3e8692a (diff)
downloadopencode-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.tsx282
-rw-r--r--packages/app/src/context/prompt.tsx64
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(() => {