Skip to content
Closed
119 changes: 93 additions & 26 deletions packages/opencode/src/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,33 +40,89 @@ export namespace File {
export type Node = z.infer<typeof Node>

export const Content = z
.object({
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
})
.discriminatedUnion("type", [
z.object({
type: z.literal("text"),
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
}),
z.object({
type: z.literal("binary"),
content: z.string(),
mimeType: z.string(),
}),
])
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>

function getMimeType(filepath: string): string {
const ext = path.extname(filepath).toLowerCase()
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".bmp": "image/bmp",
".webp": "image/webp",
".ico": "image/x-icon",
".svg": "image/svg+xml",
".zip": "application/zip",
".tar": "application/x-tar",
".gz": "application/gzip",
".pdf": "application/pdf",
".wasm": "application/wasm",
".exe": "application/x-msdownload",
".dll": "application/x-msdownload",
".so": "application/x-sharedlib",
}
return mimeTypes[ext] || "application/octet-stream"
}

async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise<boolean> {
const ext = path.extname(filepath).toLowerCase()

if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".ico"].includes(ext)) {
return true
}

if ([".zip", ".tar", ".gz", ".exe", ".dll", ".so", ".pdf", ".wasm"].includes(ext)) {
return true
}

const stat = await file.stat()
if (stat.size === 0) return false

const bufferSize = Math.min(512, stat.size)
const buffer = await file.arrayBuffer()
const bytes = new Uint8Array(buffer.slice(0, bufferSize))

for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === 0) return true
}

return false
}

export const Event = {
Edited: Bus.event(
"file.edited",
Expand Down Expand Up @@ -188,14 +244,25 @@ export namespace File {
}))
}

export async function read(file: string) {
export async function read(file: string): Promise<Content> {
using _ = log.time("read", { file })
const project = Instance.project
const full = path.join(Instance.directory, file)
const content = await Bun.file(full)
const bunFile = Bun.file(full)

const isBinary = await isBinaryFile(full, bunFile)

if (isBinary) {
const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
const content = Buffer.from(buffer).toString("base64")
return { type: "binary", content, mimeType: getMimeType(full) }
}

const content = await bunFile
.text()
.catch(() => "")
.then((x) => x.trim())

if (project.vcs === "git") {
let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text()
Expand All @@ -206,10 +273,10 @@ export namespace File {
ignoreWhitespace: true,
})
const diff = formatPatch(patch)
return { content, patch, diff }
return { type: "text", content, patch, diff }
}
}
return { content }
return { type: "text", content }
}

export async function list(dir?: string) {
Expand Down
43 changes: 25 additions & 18 deletions packages/sdk/js/src/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -950,24 +950,31 @@ export type FileNode = {
ignored: boolean
}

export type FileContent = {
content: string
diff?: string
patch?: {
oldFileName: string
newFileName: string
oldHeader?: string
newHeader?: string
hunks: Array<{
oldStart: number
oldLines: number
newStart: number
newLines: number
lines: Array<string>
}>
index?: string
}
}
export type FileContent =
| {
type: "text"
content: string
diff?: string
patch?: {
oldFileName: string
newFileName: string
oldHeader?: string
newHeader?: string
hunks: Array<{
oldStart: number
oldLines: number
newStart: number
newLines: number
lines: Array<string>
}>
index?: string
}
}
| {
type: "binary"
content: string
mimeType: string
}

export type File = {
path: string
Expand Down
12 changes: 11 additions & 1 deletion packages/web/src/content/docs/sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ const result = await client.session.prompt({
| `find.text({ query })` | Search for text in files | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` |
| `find.files({ query })` | Find files by name | `string[]` (file paths) |
| `find.symbols({ query })` | Find workspace symbols | <a href={typesUrl}><code>Symbol[]</code></a> |
| `file.read({ query })` | Read a file | `{ type: "raw" \| "patch", content: string }` |
| `file.read({ query })` | Read a file | <a href={typesUrl}><code>FileContent</code></a> |
| `file.status({ query? })` | Get status for tracked files | <a href={typesUrl}><code>File[]</code></a> |

---
Expand All @@ -282,6 +282,16 @@ const files = await client.find.files({
const content = await client.file.read({
query: { path: "src/index.ts" },
})

// Handle text vs binary content
if (content.type === "binary") {
const dataUrl = `data:${content.mimeType};base64,${content.content}`
// For images, you can render <img src={dataUrl} /> in a browser context
} else {
// Text content with optional diff/patch metadata
console.log(content.content)
if (content.diff) console.log("Has diff available")
}
```

---
Expand Down