Kaynağa Gözat

wip(app): file context

Adam 1 ay önce
ebeveyn
işleme
78940d5b7e

+ 6 - 3
packages/app/src/app.tsx

@@ -16,6 +16,7 @@ import { GlobalSDKProvider } from "@/context/global-sdk"
 import { ServerProvider, useServer } from "@/context/server"
 import { ServerProvider, useServer } from "@/context/server"
 import { TerminalProvider } from "@/context/terminal"
 import { TerminalProvider } from "@/context/terminal"
 import { PromptProvider } from "@/context/prompt"
 import { PromptProvider } from "@/context/prompt"
+import { FileProvider } from "@/context/file"
 import { NotificationProvider } from "@/context/notification"
 import { NotificationProvider } from "@/context/notification"
 import { DialogProvider } from "@opencode-ai/ui/context/dialog"
 import { DialogProvider } from "@opencode-ai/ui/context/dialog"
 import { CommandProvider } from "@/context/command"
 import { CommandProvider } from "@/context/command"
@@ -88,9 +89,11 @@ export function App() {
                                 component={(p) => (
                                 component={(p) => (
                                   <Show when={p.params.id ?? "new"} keyed>
                                   <Show when={p.params.id ?? "new"} keyed>
                                     <TerminalProvider>
                                     <TerminalProvider>
-                                      <PromptProvider>
-                                        <Session />
-                                      </PromptProvider>
+                                      <FileProvider>
+                                        <PromptProvider>
+                                          <Session />
+                                        </PromptProvider>
+                                      </FileProvider>
                                     </TerminalProvider>
                                     </TerminalProvider>
                                   </Show>
                                   </Show>
                                 )}
                                 )}

+ 6 - 4
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 { useParams } from "@solidjs/router"
 import { createMemo } from "solid-js"
 import { createMemo } from "solid-js"
 import { useLayout } from "@/context/layout"
 import { useLayout } from "@/context/layout"
-import { useLocal } from "@/context/local"
+import { useFile } from "@/context/file"
 
 
 export function DialogSelectFile() {
 export function DialogSelectFile() {
   const layout = useLayout()
   const layout = useLayout()
-  const local = useLocal()
+  const file = useFile()
   const dialog = useDialog()
   const dialog = useDialog()
   const params = useParams()
   const params = useParams()
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -20,11 +20,13 @@ export function DialogSelectFile() {
       <List
       <List
         search={{ placeholder: "Search files", autofocus: true }}
         search={{ placeholder: "Search files", autofocus: true }}
         emptyMessage="No files found"
         emptyMessage="No files found"
-        items={local.file.searchFiles}
+        items={file.searchFiles}
         key={(x) => x}
         key={(x) => x}
         onSelect={(path) => {
         onSelect={(path) => {
           if (path) {
           if (path) {
-            tabs().open("file://" + path)
+            const value = file.tab(path)
+            tabs().open(value)
+            file.load(path)
           }
           }
           dialog.close()
           dialog.close()
         }}
         }}

+ 114 - 7
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 { createStore, produce } from "solid-js/store"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
 import { useLocal } from "@/context/local"
+import { useFile, type FileSelection } from "@/context/file"
 import {
 import {
   ContentPart,
   ContentPart,
   DEFAULT_PROMPT,
   DEFAULT_PROMPT,
@@ -83,6 +84,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const sdk = useSDK()
   const sdk = useSDK()
   const sync = useSync()
   const sync = useSync()
   const local = useLocal()
   const local = useLocal()
+  const files = useFile()
   const prompt = usePrompt()
   const prompt = usePrompt()
   const layout = useLayout()
   const layout = useLayout()
   const params = useParams()
   const params = useParams()
@@ -126,6 +128,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
 
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey()))
   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 info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const status = createMemo(
   const status = createMemo(
     () =>
     () =>
@@ -303,10 +310,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     event.preventDefault()
     event.preventDefault()
     setStore("dragging", false)
     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)) {
       if (ACCEPTED_FILE_TYPES.includes(file.type)) {
         await addImageAttachment(file)
         await addImageAttachment(file)
       }
       }
@@ -360,8 +367,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   } = useFilteredList<AtOption>({
   } = useFilteredList<AtOption>({
     items: async (query) => {
     items: async (query) => {
       const agents = agentList()
       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]
       return [...agents, ...fileOptions]
     },
     },
     key: atKey,
     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) => ({
     const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
       id: Identifier.ascending("part"),
       id: Identifier.ascending("part"),
       type: "file" as const,
       type: "file" as const,
@@ -1214,7 +1256,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }))
     }))
 
 
     const isShellMode = store.mode === "shell"
     const isShellMode = store.mode === "shell"
-    tabs().setActive(undefined)
     editorRef.innerHTML = ""
     editorRef.innerHTML = ""
     prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
     prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
     setStore("imageAttachments", [])
     setStore("imageAttachments", [])
@@ -1274,7 +1315,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       type: "text" as const,
       type: "text" as const,
       text,
       text,
     }
     }
-    const requestParts = [textPart, ...fileAttachmentParts, ...agentAttachmentParts, ...imageAttachmentParts]
+    const requestParts = [
+      textPart,
+      ...fileAttachmentParts,
+      ...contextFileParts,
+      ...agentAttachmentParts,
+      ...imageAttachmentParts,
+    ]
     const optimisticParts = requestParts.map((part) => ({
     const optimisticParts = requestParts.map((part) => ({
       ...part,
       ...part,
       sessionID: existing.id,
       sessionID: existing.id,
@@ -1413,6 +1460,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             </div>
             </div>
           </div>
           </div>
         </Show>
         </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}>
         <Show when={store.imageAttachments.length > 0}>
           <div class="flex flex-wrap gap-2 px-3 pt-3">
           <div class="flex flex-wrap gap-2 px-3 pt-3">
             <For each={store.imageAttachments}>
             <For each={store.imageAttachments}>

+ 282 - 0
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)),
+    }
+  },
+})

+ 58 - 6
packages/app/src/context/prompt.tsx

@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { batch, createMemo } from "solid-js"
 import { batch, createMemo } from "solid-js"
 import { useParams } from "@solidjs/router"
 import { useParams } from "@solidjs/router"
-import { TextSelection } from "./local"
+import type { FileSelection } from "@/context/file"
 import { persisted } from "@/utils/persist"
 import { persisted } from "@/utils/persist"
 
 
 interface PartBase {
 interface PartBase {
@@ -18,7 +18,7 @@ export interface TextPart extends PartBase {
 export interface FileAttachmentPart extends PartBase {
 export interface FileAttachmentPart extends PartBase {
   type: "file"
   type: "file"
   path: string
   path: string
-  selection?: TextSelection
+  selection?: FileSelection
 }
 }
 
 
 export interface AgentPart extends PartBase {
 export interface AgentPart extends PartBase {
@@ -37,8 +37,24 @@ export interface ImageAttachmentPart {
 export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
 export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
 export type Prompt = ContentPart[]
 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 }]
 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 {
 export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
   if (promptA.length !== promptB.length) return false
   if (promptA.length !== promptB.length) return false
   for (let i = 0; i < promptA.length; i++) {
   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) {
     if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
       return false
       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) {
     if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
       return false
       return false
@@ -61,7 +80,7 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
   return true
   return true
 }
 }
 
 
-function cloneSelection(selection?: TextSelection) {
+function cloneSelection(selection?: FileSelection) {
   if (!selection) return undefined
   if (!selection) return undefined
   return { ...selection }
   return { ...selection }
 }
 }
@@ -84,24 +103,57 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
   name: "Prompt",
   name: "Prompt",
   init: () => {
   init: () => {
     const params = useParams()
     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(
     const [store, setStore, _, ready] = persisted(
       name(),
       name(),
       createStore<{
       createStore<{
         prompt: Prompt
         prompt: Prompt
         cursor?: number
         cursor?: number
+        context: {
+          activeTab: boolean
+          items: (ContextItem & { key: string })[]
+        }
       }>({
       }>({
         prompt: clonePrompt(DEFAULT_PROMPT),
         prompt: clonePrompt(DEFAULT_PROMPT),
         cursor: undefined,
         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 {
     return {
       ready,
       ready,
       current: createMemo(() => store.prompt),
       current: createMemo(() => store.prompt),
       cursor: createMemo(() => store.cursor),
       cursor: createMemo(() => store.cursor),
       dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
       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) {
       set(prompt: Prompt, cursorPosition?: number) {
         const next = clonePrompt(prompt)
         const next = clonePrompt(prompt)
         batch(() => {
         batch(() => {

+ 201 - 106
packages/app/src/pages/session.tsx

@@ -5,8 +5,8 @@ import {
   Show,
   Show,
   Match,
   Match,
   Switch,
   Switch,
-  createResource,
   createMemo,
   createMemo,
+  createResource,
   createEffect,
   createEffect,
   on,
   on,
   createRenderEffect,
   createRenderEffect,
@@ -14,7 +14,8 @@ import {
 } from "solid-js"
 } from "solid-js"
 
 
 import { Dynamic } from "solid-js/web"
 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 { createStore } from "solid-js/store"
 import { PromptInput } from "@/components/prompt-input"
 import { PromptInput } from "@/components/prompt-input"
 import { SessionContextUsage } from "@/components/session-context-usage"
 import { SessionContextUsage } from "@/components/session-context-usage"
@@ -276,6 +277,7 @@ function Header(props: { onMobileMenuToggle?: () => void }) {
 export default function Page() {
 export default function Page() {
   const layout = useLayout()
   const layout = useLayout()
   const local = useLocal()
   const local = useLocal()
+  const file = useFile()
   const sync = useSync()
   const sync = useSync()
   const terminal = useTerminal()
   const terminal = useTerminal()
   const dialog = useDialog()
   const dialog = useDialog()
@@ -289,6 +291,58 @@ export default function Page() {
   const permission = usePermission()
   const permission = usePermission()
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey()))
   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 info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const revertMessageID = createMemo(() => info()?.revert?.messageID)
   const revertMessageID = createMemo(() => info()?.revert?.messageID)
   const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
   const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
@@ -322,7 +376,6 @@ export default function Page() {
   )
   )
 
 
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
-    clickTimer: undefined as number | undefined,
     activeDraggable: undefined as string | undefined,
     activeDraggable: undefined as string | undefined,
     activeTerminalDraggable: undefined as string | undefined,
     activeTerminalDraggable: undefined as string | undefined,
     userInteracted: false,
     userInteracted: false,
@@ -659,30 +712,6 @@ export default function Page() {
     document.removeEventListener("keydown", handleKeyDown)
     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 handleDragStart = (event: unknown) => {
     const id = getDraggableId(event)
     const id = getDraggableId(event)
     if (!id) return
     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 (
     return (
       <div class="flex items-center gap-x-1.5">
       <div class="flex items-center gap-x-1.5">
         <FileIcon
         <FileIcon
-          node={props.file}
+          node={{ path: props.path, type: "file" }}
           classList={{
           classList={{
             "grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
             "grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
             "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>
       </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 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 (
     return (
       // @ts-ignore
       // @ts-ignore
       <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
       <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
@@ -811,11 +807,8 @@ export default function Page() {
               </Tooltip>
               </Tooltip>
             }
             }
             hideCloseButton
             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>
           </Tabs.Trigger>
         </div>
         </div>
       </div>
       </div>
@@ -1377,7 +1370,7 @@ export default function Page() {
             >
             >
               <DragDropSensors />
               <DragDropSensors />
               <ConstrainDragYAxis />
               <ConstrainDragYAxis />
-              <Tabs value={activeTab()} onChange={tabs().open}>
+              <Tabs value={activeTab()} onChange={openTab}>
                 <div class="sticky top-0 shrink-0 flex">
                 <div class="sticky top-0 shrink-0 flex">
                   <Tabs.List>
                   <Tabs.List>
                     <Show when={diffs().length}>
                     <Show when={diffs().length}>
@@ -1414,9 +1407,7 @@ export default function Page() {
                       </Tabs.Trigger>
                       </Tabs.Trigger>
                     </Show>
                     </Show>
                     <SortableProvider ids={openedTabs()}>
                     <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>
                     </SortableProvider>
                     <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
                     <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
                       <TooltipKeybind
                       <TooltipKeybind
@@ -1459,31 +1450,143 @@ export default function Page() {
                 </Show>
                 </Show>
                 <For each={openedTabs()}>
                 <For each={openedTabs()}>
                   {(tab) => {
                   {(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 (
                     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>
                           </Match>
                         </Switch>
                         </Switch>
                       </Tabs.Content>
                       </Tabs.Content>
@@ -1493,19 +1596,11 @@ export default function Page() {
               </Tabs>
               </Tabs>
               <DragOverlay>
               <DragOverlay>
                 <Show when={store.activeDraggable}>
                 <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 (
                     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">
                       <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>
                       </div>
                     )
                     )
                   }}
                   }}

+ 5 - 5
packages/console/app/src/config.ts

@@ -9,8 +9,8 @@ export const config = {
   github: {
   github: {
     repoUrl: "https://github.com/sst/opencode",
     repoUrl: "https://github.com/sst/opencode",
     starsFormatted: {
     starsFormatted: {
-      compact: "41K",
-      full: "41,000",
+      compact: "45K",
+      full: "45,000",
     },
     },
   },
   },
 
 
@@ -22,8 +22,8 @@ export const config = {
 
 
   // Static stats (used on landing page)
   // Static stats (used on landing page)
   stats: {
   stats: {
-    contributors: "450",
-    commits: "6,000",
-    monthlyUsers: "400,000",
+    contributors: "500",
+    commits: "6,500",
+    monthlyUsers: "650,000",
   },
   },
 } as const
 } as const

+ 102 - 3
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 { createDefaultOptions, styleVariables } from "../pierre"
 import { getWorkerPool } from "../pierre/worker"
 import { getWorkerPool } from "../pierre/worker"
 
 
+type SelectionSide = "additions" | "deletions"
+
 export type CodeProps<T = {}> = FileOptions<T> & {
 export type CodeProps<T = {}> = FileOptions<T> & {
   file: FileContents
   file: FileContents
   annotations?: LineAnnotation<T>[]
   annotations?: LineAnnotation<T>[]
+  selectedLines?: SelectedLineRange | null
   class?: string
   class?: string
   classList?: ComponentProps<"div">["classList"]
   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>) {
 export function Code<T>(props: CodeProps<T>) {
   let container!: HTMLDivElement
   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(
   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(() => {
   createEffect(() => {
     container.innerHTML = ""
     container.innerHTML = ""
     file().render({
     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 (
   return (
     <div
     <div
       data-component="code"
       data-component="code"