Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 119 additions & 20 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions nix/hashes.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-3VYF84QGROFpwBCYDEWHDpRFkSHwmiVWQJR81/bqjYo=",
"aarch64-linux": "sha256-k12n4GqrjBqKqBvLjzXQhDxbc8ZMZ/1TenDp2pKh888=",
"aarch64-darwin": "sha256-OCRX1VC5SJmrXk7whl6bsdmlRwjARGw+4RSk8c59N10=",
"x86_64-darwin": "sha256-l+g/cMREarOVIK3a01+csC3Mk3ZfMVWAiAosSA5/U6Y="
"x86_64-linux": "sha256-gdS7MkWGeVO0qLs0HKD156YE0uCk5vWeYjKu4JR1Apw=",
"aarch64-linux": "sha256-tF4pyVqzbrvdkRG23Fot37FCg8guRZkcU738fHPr/OQ=",
"aarch64-darwin": "sha256-FugTWzGMb2ktAbNwQvWRM3GWOb5RTR++8EocDDrQMLc=",
"x86_64-darwin": "sha256-jpe6EiwKr+CS00cn0eHwcDluO4LvO3t/5l/LcFBBKP0="
}
}
1 change: 0 additions & 1 deletion packages/app/src/custom-elements.d.ts

This file was deleted.

17 changes: 17 additions & 0 deletions packages/app/src/custom-elements.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { DIFFS_TAG_NAME } from "@pierre/diffs"

/**
* TypeScript declaration for the <diffs-container> custom element.
* This tells TypeScript that <diffs-container> is a valid JSX element in SolidJS.
* Required for using the @pierre/diffs web component in .tsx files.
*/

declare module "solid-js" {
namespace JSX {
interface IntrinsicElements {
[DIFFS_TAG_NAME]: HTMLAttributes<HTMLElement>
}
}
}

export {}
4 changes: 2 additions & 2 deletions packages/app/src/pages/session/file-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,8 +434,8 @@ export function FileTabContent(props: { tab: string }) {
path: path(),
current: state()?.content,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
onError: (args: { kind: "image" | "audio" | "svg" | "office" }) => {
if (args.kind !== "svg" && args.kind !== "office") return
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
Expand Down
1 change: 0 additions & 1 deletion packages/enterprise/src/custom-elements.d.ts

This file was deleted.

17 changes: 17 additions & 0 deletions packages/enterprise/src/custom-elements.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { DIFFS_TAG_NAME } from "@pierre/diffs"

/**
* TypeScript declaration for the <diffs-container> custom element.
* This tells TypeScript that <diffs-container> is a valid JSX element in SolidJS.
* Required for using the @pierre/diffs web component in .tsx files.
*/

declare module "solid-js" {
namespace JSX {
interface IntrinsicElements {
[DIFFS_TAG_NAME]: HTMLAttributes<HTMLElement>
}
}
}

export {}
26 changes: 20 additions & 6 deletions packages/opencode/src/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,6 @@ export namespace File {
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
Expand Down Expand Up @@ -282,8 +276,13 @@ export namespace File {
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
}

const office = new Set(["docx", "xlsx", "pptx"])

type Entry = { files: string[]; dirs: string[] }

const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
Expand All @@ -292,6 +291,7 @@ export namespace File {
const isTextByExtension = (file: string) => text.has(ext(file))
const isTextByName = (file: string) => textName.has(name(file))
const isBinaryByExtension = (file: string) => binary.has(ext(file))
const isOfficeByExtension = (file: string) => office.has(ext(file))
const isImage = (mimeType: string) => mimeType.startsWith("image/")
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)

Expand Down Expand Up @@ -526,6 +526,20 @@ export namespace File {
return { type: "text" as const, content: "" }
}

if (isOfficeByExtension(file)) {
const exists = yield* appFs.existsSafe(full)
if (exists) {
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
return {
type: "text" as const,
content: Buffer.from(bytes).toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64" as const,
}
}
return { type: "text" as const, content: "" }
}

const knownText = isTextByExtension(file) || isTextByName(file)

if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" }
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"jszip": "3.10.1",
"mammoth": "1.9.0",
"morphdom": "2.7.8",
"xlsx": "0.18.5",
"motion": "12.34.5",
"motion-dom": "12.34.3",
"motion-utils": "12.29.2",
Expand Down
15 changes: 14 additions & 1 deletion packages/ui/src/components/file-media.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
normalizeMimeType,
svgTextFromValue,
} from "../pierre/media"
import { OfficeViewer } from "./office-viewer"

export type FileMediaOptions = {
mode?: "auto" | "off"
Expand All @@ -19,7 +20,7 @@ export type FileMediaOptions = {
deleted?: boolean
readFile?: (path: string) => Promise<FileContent | undefined>
onLoad?: () => void
onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
onError?: (ctx: { kind: "image" | "audio" | "svg" | "office" }) => void
}

function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") {
Expand Down Expand Up @@ -247,6 +248,18 @@ export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX
)
})()}
</Match>
<Match when={kind() === "office"}>
<OfficeViewer
options={{
path: cfg()?.path,
current: cfg()?.current,
readFile: cfg()?.readFile,
onLoad,
onError: () => props.media?.onError?.({ kind: "office" }),
}}
fallback={props.fallback}
/>
</Match>
<Match when={isBinary()}>
<div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 py-10 text-center">
<div class="text-14-semibold text-text-strong">
Expand Down
94 changes: 94 additions & 0 deletions packages/ui/src/components/office-viewer.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
.office-preview {
& h1,
& h2,
& h3,
& h4,
& h5,
& h6 {
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}

& h1 {
font-size: var(--text-20);
}

& h2 {
font-size: var(--text-18);
}

& h3 {
font-size: var(--text-16);
}

& h4,
& h5,
& h6 {
font-size: var(--text-14);
}

& p {
margin-bottom: 0.5rem;
line-height: 1.6;
}

& ul,
& ol {
margin-bottom: 0.5rem;
padding-left: 1.5rem;
}

& ul {
list-style-type: disc;
}

& ol {
list-style-type: decimal;
}

& li {
margin-bottom: 0.25rem;
}

& table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
font-size: var(--text-13);
}

& th,
& td {
border: 1px solid var(--border-weak-base);
padding: 0.375rem 0.5rem;
text-align: left;
}

& th {
background-color: var(--background-stronger);
font-weight: 600;
}

& tr:nth-child(even) {
background-color: var(--background-stronger);
}

& strong {
font-weight: 600;
}

& em {
font-style: italic;
}

& a {
color: var(--accent-base);
text-decoration: underline;
}

& img {
max-width: 100%;
height: auto;
}
}
109 changes: 109 additions & 0 deletions packages/ui/src/components/office-viewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { FileContent } from "@opencode-ai/sdk/v2"
import { createResource, createSignal, Match, Switch, type JSX } from "solid-js"
import { fileExtension } from "../pierre/media"

function base64ToArrayBuffer(base64: string) {
const binary = atob(base64)
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
return bytes.buffer
}

async function renderDocx(base64: string) {
const mammoth = await import("mammoth")
const buffer = base64ToArrayBuffer(base64)
const result = await mammoth.convertToHtml({ arrayBuffer: buffer })
return result.value
}

async function renderXlsx(base64: string) {
const XLSX = await import("xlsx")
const buffer = base64ToArrayBuffer(base64)
const workbook = XLSX.read(buffer, { type: "array" })
let html = ""
for (const name of workbook.SheetNames) {
const sheet = workbook.Sheets[name]
html += `<h3 class="text-14-semibold mb-2 mt-4">${name}</h3>`
html += XLSX.utils.sheet_to_html(sheet, { id: "", editable: false })
}
return html
}


export type OfficeViewerOptions = {
path?: string
current?: unknown
readFile?: (path: string) => Promise<FileContent | undefined>
onLoad?: () => void
onError?: () => void
}

export function OfficeViewer(props: { options?: OfficeViewerOptions; fallback: () => JSX.Element }) {
const [html, setHtml] = createSignal("")
const [error, setError] = createSignal(false)

const cfg = () => props.options

const request = () => {
const media = cfg()
if (!media?.path || !media.readFile) return
return {
key: `office:${media.path}`,
path: media.path,
ext: fileExtension(media.path),
readFile: media.readFile,
}
}

const [loaded] = createResource(request, async (input) => {
const result = await input.readFile(input.path)
const content = (result as any)?.content
if (typeof content !== "string" || !content) {
setError(true)
cfg()?.onError?.()
return { key: input.key, error: true as const }
}

try {
let rendered = ""
if (input.ext === "docx") rendered = await renderDocx(content)
else if (input.ext === "xlsx") rendered = await renderXlsx(content)
else if (input.ext === "pptx") rendered = "<p class=\"text-14-regular text-text-weak\">PPT preview is not supported yet.</p>"
else rendered = "<p>Unsupported office format</p>"

setHtml(rendered)
cfg()?.onLoad?.()
return { key: input.key, value: rendered }
} catch (e) {
setError(true)
cfg()?.onError?.()
return { key: input.key, error: true as const }
}
})

const ready = () => {
const req = request()
const val = loaded()
if (!req) return false
return !loaded.loading && val && "value" in val && val.key === req.key
}

return (
<Switch>
<Match when={error() || (ready() && !html())}>
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
Failed to load office document
</div>
</Match>
<Match when={loaded.loading}>
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">Loading...</div>
</Match>
<Match when={ready()}>
<div
class="office-preview px-6 py-4 text-text-base max-w-full overflow-auto"
innerHTML={html()}
/>
</Match>
<Match when={true}>{props.fallback()}</Match>
</Switch>
)
}
4 changes: 3 additions & 1 deletion packages/ui/src/pierre/media.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { FileContent } from "@opencode-ai/sdk/v2"

export type MediaKind = "image" | "audio" | "svg"
export type MediaKind = "image" | "audio" | "svg" | "office"

const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
const officeExtensions = new Set(["docx", "xlsx", "pptx"])

type MediaValue = unknown

Expand Down Expand Up @@ -38,6 +39,7 @@ export function mediaKindFromPath(path: string | undefined): MediaKind | undefin
if (ext === "svg") return "svg"
if (imageExtensions.has(ext)) return "image"
if (audioExtensions.has(ext)) return "audio"
if (officeExtensions.has(ext)) return "office"
}

export function isBinaryContent(value: MediaValue) {
Expand Down
Loading
Loading