Просмотр исходного кода

fix(app): binary file handling in file view (#11312)

Alex Yaroshuk 2 недель назад
Родитель
Сommit
23c803707d

+ 1 - 0
packages/app/src/i18n/ar.ts

@@ -432,6 +432,7 @@ export const dict = {
   "session.review.noChanges": "لا توجد تغييرات",
   "session.files.selectToOpen": "اختر ملفًا لفتحه",
   "session.files.all": "كل الملفات",
+  "session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
   "session.messages.renderEarlier": "عرض الرسائل السابقة",
   "session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
   "session.messages.loadEarlier": "تحميل الرسائل السابقة",

+ 1 - 0
packages/app/src/i18n/br.ts

@@ -433,6 +433,7 @@ export const dict = {
   "session.review.noChanges": "Sem alterações",
   "session.files.selectToOpen": "Selecione um arquivo para abrir",
   "session.files.all": "Todos os arquivos",
+  "session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)",
   "session.messages.renderEarlier": "Renderizar mensagens anteriores",
   "session.messages.loadingEarlier": "Carregando mensagens anteriores...",
   "session.messages.loadEarlier": "Carregar mensagens anteriores",

+ 1 - 0
packages/app/src/i18n/da.ts

@@ -434,6 +434,7 @@ export const dict = {
   "session.review.noChanges": "Ingen ændringer",
   "session.files.selectToOpen": "Vælg en fil at åbne",
   "session.files.all": "Alle filer",
+  "session.files.binaryContent": "Binær fil (indhold kan ikke vises)",
   "session.messages.renderEarlier": "Vis tidligere beskeder",
   "session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
   "session.messages.loadEarlier": "Indlæs tidligere beskeder",

+ 1 - 0
packages/app/src/i18n/de.ts

@@ -442,6 +442,7 @@ export const dict = {
   "session.review.noChanges": "Keine Änderungen",
   "session.files.selectToOpen": "Datei zum Öffnen auswählen",
   "session.files.all": "Alle Dateien",
+  "session.files.binaryContent": "Binärdatei (Inhalt kann nicht angezeigt werden)",
   "session.messages.renderEarlier": "Frühere Nachrichten rendern",
   "session.messages.loadingEarlier": "Lade frühere Nachrichten...",
   "session.messages.loadEarlier": "Frühere Nachrichten laden",

+ 1 - 0
packages/app/src/i18n/en.ts

@@ -441,6 +441,7 @@ export const dict = {
 
   "session.files.selectToOpen": "Select a file to open",
   "session.files.all": "All files",
+  "session.files.binaryContent": "Binary file (content cannot be displayed)",
 
   "session.messages.renderEarlier": "Render earlier messages",
   "session.messages.loadingEarlier": "Loading earlier messages...",

+ 1 - 0
packages/app/src/i18n/es.ts

@@ -436,6 +436,7 @@ export const dict = {
   "session.review.noChanges": "Sin cambios",
   "session.files.selectToOpen": "Selecciona un archivo para abrir",
   "session.files.all": "Todos los archivos",
+  "session.files.binaryContent": "Archivo binario (el contenido no puede ser mostrado)",
   "session.messages.renderEarlier": "Renderizar mensajes anteriores",
   "session.messages.loadingEarlier": "Cargando mensajes anteriores...",
   "session.messages.loadEarlier": "Cargar mensajes anteriores",

+ 1 - 0
packages/app/src/i18n/fr.ts

@@ -441,6 +441,7 @@ export const dict = {
   "session.review.noChanges": "Aucune modification",
   "session.files.selectToOpen": "Sélectionnez un fichier à ouvrir",
   "session.files.all": "Tous les fichiers",
+  "session.files.binaryContent": "Fichier binaire (le contenu ne peut pas être affiché)",
   "session.messages.renderEarlier": "Afficher les messages précédents",
   "session.messages.loadingEarlier": "Chargement des messages précédents...",
   "session.messages.loadEarlier": "Charger les messages précédents",

+ 1 - 0
packages/app/src/i18n/ja.ts

@@ -433,6 +433,7 @@ export const dict = {
   "session.review.noChanges": "変更なし",
   "session.files.selectToOpen": "開くファイルを選択",
   "session.files.all": "すべてのファイル",
+  "session.files.binaryContent": "バイナリファイル(内容を表示できません)",
   "session.messages.renderEarlier": "以前のメッセージを表示",
   "session.messages.loadingEarlier": "以前のメッセージを読み込み中...",
   "session.messages.loadEarlier": "以前のメッセージを読み込む",

+ 1 - 0
packages/app/src/i18n/ko.ts

@@ -435,6 +435,7 @@ export const dict = {
   "session.review.noChanges": "변경 없음",
   "session.files.selectToOpen": "열 파일을 선택하세요",
   "session.files.all": "모든 파일",
+  "session.files.binaryContent": "바이너리 파일 (내용을 표시할 수 없음)",
   "session.messages.renderEarlier": "이전 메시지 렌더링",
   "session.messages.loadingEarlier": "이전 메시지 로드 중...",
   "session.messages.loadEarlier": "이전 메시지 로드",

+ 1 - 0
packages/app/src/i18n/no.ts

@@ -436,6 +436,7 @@ export const dict = {
   "session.review.noChanges": "Ingen endringer",
   "session.files.selectToOpen": "Velg en fil å åpne",
   "session.files.all": "Alle filer",
+  "session.files.binaryContent": "Binær fil (innhold kan ikke vises)",
   "session.messages.renderEarlier": "Vis tidligere meldinger",
   "session.messages.loadingEarlier": "Laster inn tidligere meldinger...",
   "session.messages.loadEarlier": "Last inn tidligere meldinger",

+ 1 - 0
packages/app/src/i18n/pl.ts

@@ -435,6 +435,7 @@ export const dict = {
   "session.review.noChanges": "Brak zmian",
   "session.files.selectToOpen": "Wybierz plik do otwarcia",
   "session.files.all": "Wszystkie pliki",
+  "session.files.binaryContent": "Plik binarny (zawartość nie może być wyświetlona)",
   "session.messages.renderEarlier": "Renderuj wcześniejsze wiadomości",
   "session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...",
   "session.messages.loadEarlier": "Załaduj wcześniejsze wiadomości",

+ 1 - 0
packages/app/src/i18n/ru.ts

@@ -437,6 +437,7 @@ export const dict = {
   "session.review.noChanges": "Нет изменений",
   "session.files.selectToOpen": "Выберите файл, чтобы открыть",
   "session.files.all": "Все файлы",
+  "session.files.binaryContent": "Двоичный файл (содержимое не может быть отображено)",
   "session.messages.renderEarlier": "Показать предыдущие сообщения",
   "session.messages.loadingEarlier": "Загрузка предыдущих сообщений...",
   "session.messages.loadEarlier": "Загрузить предыдущие сообщения",

+ 1 - 0
packages/app/src/i18n/th.ts

@@ -438,6 +438,7 @@ export const dict = {
 
   "session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด",
   "session.files.all": "ไฟล์ทั้งหมด",
+  "session.files.binaryContent": "ไฟล์ไบนารี (ไม่สามารถแสดงเนื้อหาได้)",
 
   "session.messages.renderEarlier": "แสดงข้อความก่อนหน้า",
   "session.messages.loadingEarlier": "กำลังโหลดข้อความก่อนหน้า...",

+ 1 - 0
packages/app/src/i18n/zh.ts

@@ -434,6 +434,7 @@ export const dict = {
   "session.review.noChanges": "无更改",
   "session.files.selectToOpen": "选择要打开的文件",
   "session.files.all": "所有文件",
+  "session.files.binaryContent": "二进制文件(无法显示内容)",
   "session.messages.renderEarlier": "显示更早的消息",
   "session.messages.loadingEarlier": "正在加载更早的消息...",
   "session.messages.loadEarlier": "加载更早的消息",

+ 1 - 0
packages/app/src/i18n/zht.ts

@@ -431,6 +431,7 @@ export const dict = {
   "session.review.noChanges": "沒有變更",
   "session.files.selectToOpen": "選取要開啟的檔案",
   "session.files.all": "所有檔案",
+  "session.files.binaryContent": "二進位檔案(無法顯示內容)",
   "session.messages.renderEarlier": "顯示更早的訊息",
   "session.messages.loadingEarlier": "正在載入更早的訊息...",
   "session.messages.loadEarlier": "載入更早的訊息",

+ 14 - 0
packages/app/src/pages/session.tsx

@@ -2342,6 +2342,7 @@ export default function Page() {
                             const c = state()?.content
                             return c?.mimeType === "image/svg+xml"
                           })
+                          const isBinary = createMemo(() => state()?.content?.type === "binary")
                           const svgContent = createMemo(() => {
                             if (!isSvg()) return
                             const c = state()?.content
@@ -2794,6 +2795,19 @@ export default function Page() {
                                     </Show>
                                   </div>
                                 </Match>
+                                <Match when={state()?.loaded && isBinary()}>
+                                  <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
+                                    <Mark class="w-14 opacity-10" />
+                                    <div class="flex flex-col gap-2 max-w-md">
+                                      <div class="text-14-semibold text-text-strong truncate">
+                                        {path()?.split("/").pop()}
+                                      </div>
+                                      <div class="text-14-regular text-text-weak">
+                                        {language.t("session.files.binaryContent")}
+                                      </div>
+                                    </div>
+                                  </div>
+                                </Match>
                                 <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
                                 <Match when={state()?.loading}>
                                   <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>

+ 190 - 22
packages/opencode/src/file/index.ts

@@ -44,7 +44,7 @@ export namespace File {
 
   export const Content = z
     .object({
-      type: z.literal("text"),
+      type: z.enum(["text", "binary"]),
       content: z.string(),
       diff: z.string().optional(),
       patch: z
@@ -73,6 +73,174 @@ export namespace File {
     })
   export type Content = z.infer<typeof Content>
 
+  const binaryExtensions = new Set([
+    "exe",
+    "dll",
+    "pdb",
+    "bin",
+    "so",
+    "dylib",
+    "o",
+    "a",
+    "lib",
+    "wav",
+    "mp3",
+    "ogg",
+    "oga",
+    "ogv",
+    "ogx",
+    "flac",
+    "aac",
+    "wma",
+    "m4a",
+    "weba",
+    "mp4",
+    "avi",
+    "mov",
+    "wmv",
+    "flv",
+    "webm",
+    "mkv",
+    "zip",
+    "tar",
+    "gz",
+    "gzip",
+    "bz",
+    "bz2",
+    "bzip",
+    "bzip2",
+    "7z",
+    "rar",
+    "xz",
+    "lz",
+    "z",
+    "pdf",
+    "doc",
+    "docx",
+    "ppt",
+    "pptx",
+    "xls",
+    "xlsx",
+    "dmg",
+    "iso",
+    "img",
+    "vmdk",
+    "ttf",
+    "otf",
+    "woff",
+    "woff2",
+    "eot",
+    "sqlite",
+    "db",
+    "mdb",
+    "apk",
+    "ipa",
+    "aab",
+    "xapk",
+    "app",
+    "pkg",
+    "deb",
+    "rpm",
+    "snap",
+    "flatpak",
+    "appimage",
+    "msi",
+    "msp",
+    "jar",
+    "war",
+    "ear",
+    "class",
+    "kotlin_module",
+    "dex",
+    "vdex",
+    "odex",
+    "oat",
+    "art",
+    "wasm",
+    "wat",
+    "bc",
+    "ll",
+    "s",
+    "ko",
+    "sys",
+    "drv",
+    "efi",
+    "rom",
+    "com",
+    "bat",
+    "cmd",
+    "ps1",
+    "sh",
+    "bash",
+    "zsh",
+    "fish",
+  ])
+
+  const imageExtensions = new Set([
+    "png",
+    "jpg",
+    "jpeg",
+    "gif",
+    "bmp",
+    "webp",
+    "ico",
+    "tif",
+    "tiff",
+    "svg",
+    "svgz",
+    "avif",
+    "apng",
+    "jxl",
+    "heic",
+    "heif",
+    "raw",
+    "cr2",
+    "nef",
+    "arw",
+    "dng",
+    "orf",
+    "raf",
+    "pef",
+    "x3f",
+  ])
+
+  function isImageByExtension(filepath: string): boolean {
+    const ext = path.extname(filepath).toLowerCase().slice(1)
+    return imageExtensions.has(ext)
+  }
+
+  function getImageMimeType(filepath: string): string {
+    const ext = path.extname(filepath).toLowerCase().slice(1)
+    const mimeTypes: Record<string, string> = {
+      png: "image/png",
+      jpg: "image/jpeg",
+      jpeg: "image/jpeg",
+      gif: "image/gif",
+      bmp: "image/bmp",
+      webp: "image/webp",
+      ico: "image/x-icon",
+      tif: "image/tiff",
+      tiff: "image/tiff",
+      svg: "image/svg+xml",
+      svgz: "image/svg+xml",
+      avif: "image/avif",
+      apng: "image/apng",
+      jxl: "image/jxl",
+      heic: "image/heic",
+      heif: "image/heif",
+    }
+    return mimeTypes[ext] || "image/" + ext
+  }
+
+  function isBinaryByExtension(filepath: string): boolean {
+    const ext = path.extname(filepath).toLowerCase().slice(1)
+    return binaryExtensions.has(ext)
+  }
+
+  function isImage(mimeType: string): boolean {
+    return mimeType.startsWith("image/")
+  }
+
   async function shouldEncode(file: BunFile): Promise<boolean> {
     const type = file.type?.toLowerCase()
     log.info("shouldEncode", { type })
@@ -83,30 +251,10 @@ export namespace File {
 
     const parts = type.split("/", 2)
     const top = parts[0]
-    const rest = parts[1] ?? ""
-    const sub = rest.split(";", 1)[0]
 
     const tops = ["image", "audio", "video", "font", "model", "multipart"]
     if (tops.includes(top)) return true
 
-    const bins = [
-      "zip",
-      "gzip",
-      "bzip",
-      "compressed",
-      "binary",
-      "pdf",
-      "msword",
-      "powerpoint",
-      "excel",
-      "ogg",
-      "exe",
-      "dmg",
-      "iso",
-      "rar",
-    ]
-    if (bins.some((mark) => sub.includes(mark))) return true
-
     return false
   }
 
@@ -287,6 +435,22 @@ export namespace File {
       throw new Error(`Access denied: path escapes project directory`)
     }
 
+    // Fast path: check extension before any filesystem operations
+    if (isImageByExtension(file)) {
+      const bunFile = Bun.file(full)
+      if (await bunFile.exists()) {
+        const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
+        const content = Buffer.from(buffer).toString("base64")
+        const mimeType = getImageMimeType(file)
+        return { type: "text", content, mimeType, encoding: "base64" }
+      }
+      return { type: "text", content: "" }
+    }
+
+    if (isBinaryByExtension(file)) {
+      return { type: "binary", content: "" }
+    }
+
     const bunFile = Bun.file(full)
 
     if (!(await bunFile.exists())) {
@@ -294,11 +458,15 @@ export namespace File {
     }
 
     const encode = await shouldEncode(bunFile)
+    const mimeType = bunFile.type || "application/octet-stream"
+
+    if (encode && !isImage(mimeType)) {
+      return { type: "binary", content: "", mimeType }
+    }
 
     if (encode) {
       const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
       const content = Buffer.from(buffer).toString("base64")
-      const mimeType = bunFile.type || "application/octet-stream"
       return { type: "text", content, mimeType, encoding: "base64" }
     }
 

+ 1 - 1
packages/sdk/js/src/gen/types.gen.ts

@@ -1554,7 +1554,7 @@ export type FileNode = {
 }
 
 export type FileContent = {
-  type: "text"
+  type: "text" | "binary"
   content: string
   diff?: string
   patch?: {

+ 1 - 1
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -2042,7 +2042,7 @@ export type FileNode = {
 }
 
 export type FileContent = {
-  type: "text"
+  type: "text" | "binary"
   content: string
   diff?: string
   patch?: {