summaryrefslogtreecommitdiffhomepage
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
parentb84a1f714bf0b81efdf89a0dd6e35fa2b3e8692a (diff)
downloadopencode-78940d5b7ee2f3e5020f87b400db1785b37a7d71.tar.gz
opencode-78940d5b7ee2f3e5020f87b400db1785b37a7d71.zip
wip(app): file context
-rw-r--r--packages/app/src/app.tsx9
-rw-r--r--packages/app/src/components/dialog-select-file.tsx10
-rw-r--r--packages/app/src/components/prompt-input.tsx121
-rw-r--r--packages/app/src/context/file.tsx282
-rw-r--r--packages/app/src/context/prompt.tsx64
-rw-r--r--packages/app/src/pages/session.tsx307
-rw-r--r--packages/console/app/src/config.ts10
-rw-r--r--packages/ui/src/components/code.tsx105
8 files changed, 774 insertions, 134 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index b4bae7dc8..e41575e7a 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -16,6 +16,7 @@ import { GlobalSDKProvider } from "@/context/global-sdk"
import { ServerProvider, useServer } from "@/context/server"
import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
+import { FileProvider } from "@/context/file"
import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
@@ -88,9 +89,11 @@ export function App() {
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
- <PromptProvider>
- <Session />
- </PromptProvider>
+ <FileProvider>
+ <PromptProvider>
+ <Session />
+ </PromptProvider>
+ </FileProvider>
</TerminalProvider>
</Show>
)}
diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx
index b27afdc8b..8e68a3eb8 100644
--- a/packages/app/src/components/dialog-select-file.tsx
+++ b/packages/app/src/components/dialog-select-file.tsx
@@ -6,11 +6,11 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout"
-import { useLocal } from "@/context/local"
+import { useFile } from "@/context/file"
export function DialogSelectFile() {
const layout = useLayout()
- const local = useLocal()
+ const file = useFile()
const dialog = useDialog()
const params = useParams()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -20,11 +20,13 @@ export function DialogSelectFile() {
<List
search={{ placeholder: "Search files", autofocus: true }}
emptyMessage="No files found"
- items={local.file.searchFiles}
+ items={file.searchFiles}
key={(x) => x}
onSelect={(path) => {
if (path) {
- tabs().open("file://" + path)
+ const value = file.tab(path)
+ tabs().open(value)
+ file.load(path)
}
dialog.close()
}}
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 855eb31e1..967b17606 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -3,6 +3,7 @@ import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Mat
import { createStore, produce } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
+import { useFile, type FileSelection } from "@/context/file"
import {
ContentPart,
DEFAULT_PROMPT,
@@ -83,6 +84,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const sync = useSync()
const local = useLocal()
+ const files = useFile()
const prompt = usePrompt()
const layout = useLayout()
const params = useParams()
@@ -126,6 +128,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
+ const activeFile = createMemo(() => {
+ const tab = tabs().active()
+ if (!tab) return
+ return files.pathFromTab(tab)
+ })
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const status = createMemo(
() =>
@@ -303,10 +310,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
event.preventDefault()
setStore("dragging", false)
- const files = event.dataTransfer?.files
- if (!files) return
+ const dropped = event.dataTransfer?.files
+ if (!dropped) return
- for (const file of Array.from(files)) {
+ for (const file of Array.from(dropped)) {
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
await addImageAttachment(file)
}
@@ -360,8 +367,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} = useFilteredList<AtOption>({
items: async (query) => {
const agents = agentList()
- const files = await local.file.searchFilesAndDirectories(query)
- const fileOptions: AtOption[] = files.map((path) => ({ type: "file", path, display: path }))
+ const paths = await files.searchFilesAndDirectories(query)
+ const fileOptions: AtOption[] = paths.map((path) => ({ type: "file", path, display: path }))
return [...agents, ...fileOptions]
},
key: atKey,
@@ -1205,6 +1212,41 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
}))
+ const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
+
+ const contextFileParts: Array<{
+ id: string
+ type: "file"
+ mime: string
+ url: string
+ filename?: string
+ }> = []
+
+ const addContextFile = (path: string, selection?: FileSelection) => {
+ const absolute = toAbsolutePath(path)
+ const query = selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
+ const url = `file://${absolute}${query}`
+ if (usedUrls.has(url)) return
+ usedUrls.add(url)
+ contextFileParts.push({
+ id: Identifier.ascending("part"),
+ type: "file",
+ mime: "text/plain",
+ url,
+ filename: getFilename(path),
+ })
+ }
+
+ const activePath = activeFile()
+ if (activePath && prompt.context.activeTab()) {
+ addContextFile(activePath)
+ }
+
+ for (const item of prompt.context.items()) {
+ if (item.type !== "file") continue
+ addContextFile(item.path, item.selection)
+ }
+
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
@@ -1214,7 +1256,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}))
const isShellMode = store.mode === "shell"
- tabs().setActive(undefined)
editorRef.innerHTML = ""
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
setStore("imageAttachments", [])
@@ -1274,7 +1315,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "text" as const,
text,
}
- const requestParts = [textPart, ...fileAttachmentParts, ...agentAttachmentParts, ...imageAttachmentParts]
+ const requestParts = [
+ textPart,
+ ...fileAttachmentParts,
+ ...contextFileParts,
+ ...agentAttachmentParts,
+ ...imageAttachmentParts,
+ ]
const optimisticParts = requestParts.map((part) => ({
...part,
sessionID: existing.id,
@@ -1413,6 +1460,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</div>
</Show>
+ <Show when={prompt.context.items().length > 0 || !!activeFile()}>
+ <div class="flex flex-wrap items-center gap-2 px-3 pt-3">
+ <Show when={prompt.context.activeTab() ? activeFile() : undefined}>
+ {(path) => (
+ <div class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base max-w-full">
+ <FileIcon node={{ path: path(), type: "file" }} class="shrink-0 size-4" />
+ <div class="flex items-center text-12-regular min-w-0">
+ <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(path())}</span>
+ <span class="text-text-strong whitespace-nowrap">{getFilename(path())}</span>
+ <span class="text-text-weak whitespace-nowrap ml-1">active</span>
+ </div>
+ <IconButton
+ type="button"
+ icon="close"
+ variant="ghost"
+ class="h-6 w-6"
+ onClick={() => prompt.context.removeActive()}
+ />
+ </div>
+ )}
+ </Show>
+ <Show when={!prompt.context.activeTab() && !!activeFile()}>
+ <button
+ type="button"
+ class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-weak hover:bg-surface-raised-base-hover"
+ onClick={() => prompt.context.addActive()}
+ >
+ <Icon name="plus-small" size="small" />
+ <span>Include active file</span>
+ </button>
+ </Show>
+ <For each={prompt.context.items()}>
+ {(item) => (
+ <div class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base max-w-full">
+ <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
+ <div class="flex items-center text-12-regular min-w-0">
+ <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span>
+ <span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
+ <Show when={item.selection}>
+ {(sel) => (
+ <span class="text-text-weak whitespace-nowrap ml-1">
+ {sel().startLine === sel().endLine
+ ? `:${sel().startLine}`
+ : `:${sel().startLine}-${sel().endLine}`}
+ </span>
+ )}
+ </Show>
+ </div>
+ <IconButton
+ type="button"
+ icon="close"
+ variant="ghost"
+ class="h-6 w-6"
+ onClick={() => prompt.context.remove(item.key)}
+ />
+ </div>
+ )}
+ </For>
+ </div>
+ </Show>
<Show when={store.imageAttachments.length > 0}>
<div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={store.imageAttachments}>
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(() => {
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index f738fec33..f0e6a6e1d 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -5,8 +5,8 @@ import {
Show,
Match,
Switch,
- createResource,
createMemo,
+ createResource,
createEffect,
on,
createRenderEffect,
@@ -14,7 +14,8 @@ import {
} from "solid-js"
import { Dynamic } from "solid-js/web"
-import { useLocal, type LocalFile } from "@/context/local"
+import { useLocal } from "@/context/local"
+import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input"
import { SessionContextUsage } from "@/components/session-context-usage"
@@ -276,6 +277,7 @@ function Header(props: { onMobileMenuToggle?: () => void }) {
export default function Page() {
const layout = useLayout()
const local = useLocal()
+ const file = useFile()
const sync = useSync()
const terminal = useTerminal()
const dialog = useDialog()
@@ -289,6 +291,58 @@ export default function Page() {
const permission = usePermission()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
+
+ function normalizeTab(tab: string) {
+ if (!tab.startsWith("file://")) return tab
+ return file.tab(tab)
+ }
+
+ function normalizeTabs(list: string[]) {
+ const seen = new Set<string>()
+ const next: string[] = []
+ for (const item of list) {
+ const value = normalizeTab(item)
+ if (seen.has(value)) continue
+ seen.add(value)
+ next.push(value)
+ }
+ return next
+ }
+
+ const openTab = (value: string) => {
+ const next = normalizeTab(value)
+ tabs().open(next)
+
+ const path = file.pathFromTab(next)
+ if (path) file.load(path)
+ }
+
+ createEffect(() => {
+ const active = tabs().active()
+ if (!active) return
+
+ const path = file.pathFromTab(active)
+ if (path) file.load(path)
+ })
+
+ createEffect(() => {
+ const current = tabs().all()
+ if (current.length === 0) return
+
+ const next = normalizeTabs(current)
+ if (same(current, next)) return
+
+ tabs().setAll(next)
+
+ const active = tabs().active()
+ if (!active) return
+ if (!active.startsWith("file://")) return
+
+ const normalized = normalizeTab(active)
+ if (active === normalized) return
+ tabs().setActive(normalized)
+ })
+
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
@@ -322,7 +376,6 @@ export default function Page() {
)
const [store, setStore] = createStore({
- clickTimer: undefined as number | undefined,
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
userInteracted: false,
@@ -659,30 +712,6 @@ export default function Page() {
document.removeEventListener("keydown", handleKeyDown)
})
- const resetClickTimer = () => {
- if (!store.clickTimer) return
- clearTimeout(store.clickTimer)
- setStore("clickTimer", undefined)
- }
-
- const startClickTimer = () => {
- const newClickTimer = setTimeout(() => {
- setStore("clickTimer", undefined)
- }, 300)
- setStore("clickTimer", newClickTimer as unknown as number)
- }
-
- const handleTabClick = async (tab: string) => {
- if (store.clickTimer) {
- resetClickTimer()
- } else {
- if (tab.startsWith("file://")) {
- local.file.open(tab.replace("file://", ""))
- }
- startClickTimer()
- }
- }
-
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
@@ -748,57 +777,24 @@ export default function Page() {
)
}
- const FileVisual = (props: { file: LocalFile; active?: boolean }): JSX.Element => {
+ const FileVisual = (props: { path: string; active?: boolean }): JSX.Element => {
return (
<div class="flex items-center gap-x-1.5">
<FileIcon
- node={props.file}
+ node={{ path: props.path, type: "file" }}
classList={{
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
"grayscale-0": props.active,
}}
/>
- <span
- classList={{
- "text-14-medium": true,
- "text-primary": !!props.file.status?.status,
- italic: !props.file.pinned,
- }}
- >
- {props.file.name}
- </span>
- <span class="hidden opacity-70">
- <Switch>
- <Match when={props.file.status?.status === "modified"}>
- <span class="text-primary">M</span>
- </Match>
- <Match when={props.file.status?.status === "added"}>
- <span class="text-success">A</span>
- </Match>
- <Match when={props.file.status?.status === "deleted"}>
- <span class="text-error">D</span>
- </Match>
- </Switch>
- </span>
+ <span class="text-14-medium">{getFilename(props.path)}</span>
</div>
)
}
- const SortableTab = (props: {
- tab: string
- onTabClick: (tab: string) => void
- onTabClose: (tab: string) => void
- }): JSX.Element => {
+ const SortableTab = (props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element => {
const sortable = createSortable(props.tab)
- const [file] = createResource(
- () => props.tab,
- async (tab) => {
- if (tab.startsWith("file://")) {
- return local.file.node(tab.replace("file://", ""))
- }
- return undefined
- },
- )
+ const path = createMemo(() => file.pathFromTab(props.tab))
return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
@@ -811,11 +807,8 @@ export default function Page() {
</Tooltip>
}
hideCloseButton
- onClick={() => props.onTabClick(props.tab)}
>
- <Switch>
- <Match when={file()}>{(f) => <FileVisual file={f()} />}</Match>
- </Switch>
+ <Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
</Tabs.Trigger>
</div>
</div>
@@ -1377,7 +1370,7 @@ export default function Page() {
>
<DragDropSensors />
<ConstrainDragYAxis />
- <Tabs value={activeTab()} onChange={tabs().open}>
+ <Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Show when={diffs().length}>
@@ -1414,9 +1407,7 @@ export default function Page() {
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
- <For each={openedTabs()}>
- {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
- </For>
+ <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
<TooltipKeybind
@@ -1459,31 +1450,143 @@ export default function Page() {
</Show>
<For each={openedTabs()}>
{(tab) => {
- const [file] = createResource(
- () => tab,
- async (tab) => {
- if (tab.startsWith("file://")) {
- return local.file.node(tab.replace("file://", ""))
- }
- return undefined
- },
+ let scroll: HTMLDivElement | undefined
+ let scrollFrame: number | undefined
+ let pendingTop: number | undefined
+
+ const path = createMemo(() => file.pathFromTab(tab))
+ const state = createMemo(() => {
+ const p = path()
+ if (!p) return
+ return file.get(p)
+ })
+ const contents = createMemo(() => state()?.content?.content ?? "")
+ const selectedLines = createMemo(() => {
+ const p = path()
+ if (!p) return null
+ return file.selectedLines(p) ?? null
+ })
+ const selection = createMemo(() => {
+ const range = selectedLines()
+ if (!range) return
+ return selectionFromLines(range)
+ })
+ const selectionLabel = createMemo(() => {
+ const sel = selection()
+ if (!sel) return
+ if (sel.startLine === sel.endLine) return `L${sel.startLine}`
+ return `L${sel.startLine}-${sel.endLine}`
+ })
+
+ const restoreScroll = () => {
+ const el = scroll
+ const p = path()
+ if (!el || !p) return
+
+ const top = file.scrollTop(p)
+ if (top === undefined) return
+ if (el.scrollTop === top) return
+ el.scrollTop = top
+ }
+
+ const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+ const p = path()
+ if (!p) return
+
+ pendingTop = event.currentTarget.scrollTop
+ if (scrollFrame !== undefined) return
+
+ scrollFrame = requestAnimationFrame(() => {
+ scrollFrame = undefined
+
+ const top = pendingTop
+ pendingTop = undefined
+ if (top === undefined) return
+
+ file.setScrollTop(p, top)
+ })
+ }
+
+ createEffect(
+ on(
+ () => state()?.loaded,
+ (loaded) => {
+ if (!loaded) return
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => file.ready(),
+ (ready) => {
+ if (!ready) return
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
)
+
+ onCleanup(() => {
+ if (scrollFrame === undefined) return
+ cancelAnimationFrame(scrollFrame)
+ })
+
return (
- <Tabs.Content value={tab} class="mt-3">
- <Switch>
- <Match when={file()}>
- {(f) => (
- <Dynamic
- component={codeComponent}
- file={{
- name: f().path,
- contents: f().content?.content ?? "",
- cacheKey: checksum(f().content?.content ?? ""),
+ <Tabs.Content
+ value={tab}
+ class="mt-3"
+ ref={(el: HTMLDivElement) => {
+ scroll = el
+ restoreScroll()
+ }}
+ onScroll={handleScroll}
+ >
+ <Show when={selection()}>
+ {(sel) => (
+ <div class="sticky top-0 z-10 px-6 py-2 flex justify-end bg-background-base border-b border-border-weak-base">
+ <button
+ type="button"
+ class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
+ onClick={() => {
+ const p = path()
+ if (!p) return
+ prompt.context.add({ type: "file", path: p, selection: sel() })
}}
- overflow="scroll"
- class="select-text pb-40"
- />
- )}
+ >
+ <Icon name="plus-small" size="small" />
+ <span>Add {selectionLabel()} to context</span>
+ </button>
+ </div>
+ )}
+ </Show>
+ <Switch>
+ <Match when={state()?.loaded}>
+ <Dynamic
+ component={codeComponent}
+ file={{
+ name: path() ?? "",
+ contents: contents(),
+ cacheKey: checksum(contents()),
+ }}
+ enableLineSelection
+ selectedLines={selectedLines()}
+ onLineSelected={(range: SelectedLineRange | null) => {
+ const p = path()
+ if (!p) return
+ file.setSelectedLines(p, range)
+ }}
+ overflow="scroll"
+ class="select-text pb-40"
+ />
+ </Match>
+ <Match when={state()?.loading}>
+ <div class="px-6 py-4 text-text-weak">Loading...</div>
+ </Match>
+ <Match when={state()?.error}>
+ {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
</Match>
</Switch>
</Tabs.Content>
@@ -1493,19 +1596,11 @@ export default function Page() {
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable}>
- {(draggedFile) => {
- const [file] = createResource(
- () => draggedFile(),
- async (tab) => {
- if (tab.startsWith("file://")) {
- return local.file.node(tab.replace("file://", ""))
- }
- return undefined
- },
- )
+ {(tab) => {
+ const path = createMemo(() => file.pathFromTab(tab()))
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
- <Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
+ <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts
index bf20681ae..8d7da0b97 100644
--- a/packages/console/app/src/config.ts
+++ b/packages/console/app/src/config.ts
@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/sst/opencode",
starsFormatted: {
- compact: "41K",
- full: "41,000",
+ compact: "45K",
+ full: "45,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
- contributors: "450",
- commits: "6,000",
- monthlyUsers: "400,000",
+ contributors: "500",
+ commits: "6,500",
+ monthlyUsers: "650,000",
},
} as const
diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx
index fda08260f..ed7db368c 100644
--- a/packages/ui/src/components/code.tsx
+++ b/packages/ui/src/components/code.tsx
@@ -1,18 +1,52 @@
-import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/diffs"
-import { ComponentProps, createEffect, createMemo, splitProps } from "solid-js"
+import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
+import { ComponentProps, createEffect, createMemo, onCleanup, splitProps } from "solid-js"
import { createDefaultOptions, styleVariables } from "../pierre"
import { getWorkerPool } from "../pierre/worker"
+type SelectionSide = "additions" | "deletions"
+
export type CodeProps<T = {}> = FileOptions<T> & {
file: FileContents
annotations?: LineAnnotation<T>[]
+ selectedLines?: SelectedLineRange | null
class?: string
classList?: ComponentProps<"div">["classList"]
}
+function findElement(node: Node | null): HTMLElement | undefined {
+ if (!node) return
+ if (node instanceof HTMLElement) return node
+ return node.parentElement ?? undefined
+}
+
+function findLineNumber(node: Node | null): number | undefined {
+ const element = findElement(node)
+ if (!element) return
+
+ const line = element.closest("[data-line]")
+ if (!(line instanceof HTMLElement)) return
+
+ const value = parseInt(line.dataset.line ?? "", 10)
+ if (Number.isNaN(value)) return
+
+ return value
+}
+
+function findSide(node: Node | null): SelectionSide | undefined {
+ const element = findElement(node)
+ if (!element) return
+
+ const code = element.closest("[data-code]")
+ if (!(code instanceof HTMLElement)) return
+
+ if (code.hasAttribute("data-deletions")) return "deletions"
+ return "additions"
+}
+
export function Code<T>(props: CodeProps<T>) {
let container!: HTMLDivElement
- const [local, others] = splitProps(props, ["file", "class", "classList", "annotations"])
+
+ const [local, others] = splitProps(props, ["file", "class", "classList", "annotations", "selectedLines"])
const file = createMemo(
() =>
@@ -25,6 +59,57 @@ export function Code<T>(props: CodeProps<T>) {
),
)
+ const getRoot = () => {
+ const host = container.querySelector("diffs-container")
+ if (!(host instanceof HTMLElement)) return
+
+ const root = host.shadowRoot
+ if (!root) return
+
+ return root
+ }
+
+ const handleMouseUp = () => {
+ if (props.enableLineSelection !== true) return
+
+ const root = getRoot()
+ if (!root) return
+
+ const selection = window.getSelection()
+ if (!selection || selection.isCollapsed) return
+
+ const anchor = selection.anchorNode
+ const focus = selection.focusNode
+ if (!anchor || !focus) return
+ if (!root.contains(anchor) || !root.contains(focus)) return
+
+ const start = findLineNumber(anchor)
+ const end = findLineNumber(focus)
+ if (start === undefined || end === undefined) return
+
+ const startSide = findSide(anchor)
+ const endSide = findSide(focus)
+ const side = startSide ?? endSide
+
+ const range: SelectedLineRange = {
+ start,
+ end,
+ }
+
+ if (side) range.side = side
+ if (endSide && side && endSide !== side) range.endSide = endSide
+
+ file().setSelectedLines(range)
+ }
+
+ createEffect(() => {
+ const current = file()
+
+ onCleanup(() => {
+ current.cleanUp()
+ })
+ })
+
createEffect(() => {
container.innerHTML = ""
file().render({
@@ -34,6 +119,20 @@ export function Code<T>(props: CodeProps<T>) {
})
})
+ createEffect(() => {
+ file().setSelectedLines(local.selectedLines ?? null)
+ })
+
+ createEffect(() => {
+ if (props.enableLineSelection !== true) return
+
+ container.addEventListener("mouseup", handleMouseUp)
+
+ onCleanup(() => {
+ container.removeEventListener("mouseup", handleMouseUp)
+ })
+ })
+
return (
<div
data-component="code"