diff --git a/plugins/browser-inbox/frontend/src/index.js b/plugins/browser-inbox/frontend/src/index.js index 4e58fd6..d019909 100644 --- a/plugins/browser-inbox/frontend/src/index.js +++ b/plugins/browser-inbox/frontend/src/index.js @@ -216,6 +216,7 @@ fileMime: text(payload.fileMime).trim(), fileSize: Number(payload.fileSize) || 0, fileText: text(payload.fileText), + fileDataBase64: text(payload.fileDataBase64).trim(), source: text(payload.source).trim(), browserName: text(payload.browserName).trim(), workspaceRootPath: workspaceFromPayload(payload) || (scope && scope.workspaceRoot) || '' @@ -240,6 +241,7 @@ fileMime: text(item.fileMime), fileSize: Number(item.fileSize) || 0, fileText: text(item.fileText), + fileDataBase64: text(item.fileDataBase64).trim(), source: text(item.source), browserName: text(item.browserName), workspaceRootPath: cleanWorkspace(item.workspaceRootPath), @@ -264,6 +266,7 @@ fileMime: item.fileMime, fileSize: item.fileSize, fileText: item.fileText, + fileDataBase64: item.fileDataBase64, source: item.source, browserName: item.browserName, workspaceRootPath: item.workspaceRootPath, @@ -531,8 +534,8 @@ } function createFileFromCapture(capture) { - if (!capture || !capture.workspaceRootPath || capture.kind !== 'file' || !capture.fileName || !capture.fileText) return Promise.resolve(); - if (!api || !api.files || typeof api.files.writeText !== 'function') { + if (!capture || !capture.workspaceRootPath || capture.kind !== 'file' || !capture.fileName || (!capture.fileText && !capture.fileDataBase64)) return Promise.resolve(); + if (!api || !api.files || (capture.fileDataBase64 ? typeof api.files.writeBytes !== 'function' : typeof api.files.writeText !== 'function')) { statusText = 'Could not create file: files API unavailable'; statusClass = 'error'; render(); @@ -543,10 +546,14 @@ statusText = 'Creating file...'; statusClass = ''; render(); - return api.files.writeText(filePath, capture.fileText, { + var writeOptions = { createIfMissing: true, overwrite: false - }).then(function () { + }; + var writePromise = capture.fileDataBase64 + ? api.files.writeBytes(filePath, capture.fileDataBase64, writeOptions) + : api.files.writeText(filePath, capture.fileText, writeOptions); + return writePromise.then(function () { if (api.events && typeof api.events.publish === 'function') { return api.events.publish('browser.capture.converted', { captureId: capture.captureId, @@ -648,7 +655,7 @@ } })); } - if (capture.kind === 'file' && capture.fileName && capture.fileText) { + if (capture.kind === 'file' && capture.fileName && (capture.fileText || capture.fileDataBase64)) { actionButtons.push(el('button', { className: 'browser-inbox-btn', 'data-browser-inbox-action': 'create-file', diff --git a/plugins/platform-test/frontend/src/index.js b/plugins/platform-test/frontend/src/index.js index e10e55b..594ce07 100644 --- a/plugins/platform-test/frontend/src/index.js +++ b/plugins/platform-test/frontend/src/index.js @@ -436,7 +436,9 @@ { label: 'capabilities.has', ok: typeof api.capabilities.has === 'function' }, { label: 'files.list', ok: typeof api.files.list === 'function' }, { label: 'files.readText', ok: typeof api.files.readText === 'function' }, + { label: 'files.readBytes', ok: typeof api.files.readBytes === 'function' }, { label: 'files.writeText', ok: typeof api.files.writeText === 'function' }, + { label: 'files.writeBytes', ok: typeof api.files.writeBytes === 'function' }, { label: 'files.trash', ok: typeof api.files.trash === 'function' }, { label: 'workbench.openResource', ok: typeof api.workbench.openResource === 'function' }, { label: 'workbench.editResource', ok: typeof api.workbench.editResource === 'function' }, diff --git a/scripts/smoke-browser-inbox-plugin.js b/scripts/smoke-browser-inbox-plugin.js index 8e2f09b..36ab91b 100644 --- a/scripts/smoke-browser-inbox-plugin.js +++ b/scripts/smoke-browser-inbox-plugin.js @@ -135,6 +135,7 @@ function makeApi(initialSettings = {}) { const handlers = {}; const unsubscribed = []; const fileWrites = []; + const fileByteWrites = []; const publishedEvents = []; let nextWriteError = null; return { @@ -142,6 +143,7 @@ function makeApi(initialSettings = {}) { handlers, unsubscribed, fileWrites, + fileByteWrites, publishedEvents, failNextWrite(message) { nextWriteError = new Error(message || 'write failed'); @@ -174,6 +176,14 @@ function makeApi(initialSettings = {}) { } fileWrites.push({ relativePath, content, options }); }, + writeBytes: async (relativePath, dataBase64, options = {}) => { + if (nextWriteError) { + const err = nextWriteError; + nextWriteError = null; + throw err; + } + fileByteWrites.push({ relativePath, dataBase64, options }); + }, }, getStoredCaptures(key = 'captures') { return settings[key] || []; @@ -585,6 +595,41 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w if (convertedFileEvent.payload.filePath !== 'Project/Files/notes.txt') throw new Error('converted file event filePath mismatch'); component.unmount && component.unmount(fileConversionView.container); + const binaryFileApi = makeApi({ + 'captures:workspace:Project': [{ + captureId: 'convert-binary-file', + capturedAt: '2026-06-29T02:25:00.000Z', + kind: 'file', + url: 'https://example.com/files', + title: 'Example Files', + domain: 'example.com', + fileName: 'logo.png', + fileMime: 'image/png', + fileSize: 4, + fileDataBase64: 'iVBORw==', + workspaceRootPath: 'Project', + workspaceName: 'Project', + }], + }); + const binaryFileView = await mountWithApi(binaryFileApi); + const createBinaryFileButton = walk(binaryFileView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-file'); + if (!createBinaryFileButton) throw new Error('create file button for binary capture was not rendered'); + createBinaryFileButton.click(); + await flush(); + if (binaryFileApi.fileWrites.length !== 0) throw new Error('binary file conversion used writeText'); + if (binaryFileApi.fileByteWrites.length !== 1) throw new Error(`expected one byte write, got ${binaryFileApi.fileByteWrites.length}`); + const byteWrite = binaryFileApi.fileByteWrites[0]; + if (byteWrite.relativePath !== 'Project/Files/logo.png') throw new Error(`byte write path mismatch: ${byteWrite.relativePath}`); + if (byteWrite.dataBase64 !== 'iVBORw==') throw new Error(`byte write data mismatch: ${byteWrite.dataBase64}`); + if (byteWrite.options.createIfMissing !== true || byteWrite.options.overwrite !== false) { + throw new Error(`byte write options mismatch: ${JSON.stringify(byteWrite.options)}`); + } + const convertedBinaryFileEvent = binaryFileApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); + if (!convertedBinaryFileEvent || convertedBinaryFileEvent.payload.filePath !== 'Project/Files/logo.png') { + throw new Error('binary file conversion event mismatch'); + } + component.unmount && component.unmount(binaryFileView.container); + const failedFileApi = makeApi({ 'captures:workspace:Project': [{ captureId: 'convert-file-conflict', diff --git a/scripts/smoke-platform-frontend.js b/scripts/smoke-platform-frontend.js index 0377c6f..d6ef2ff 100644 --- a/scripts/smoke-platform-frontend.js +++ b/scripts/smoke-platform-frontend.js @@ -109,6 +109,15 @@ const api = { writeText: async (relativePath, content) => { api.files._entries.set(relativePath, { type: 'file', content }); }, + readBytes: async (relativePath) => { + const entry = api.files._entries.get(relativePath); + if (!entry) throw new Error(`not-found: ${relativePath}`); + const content = entry.content || ''; + return { relativePath, size: content.length, mimeHint: '', dataBase64: Buffer.from(content, 'binary').toString('base64') }; + }, + writeBytes: async (relativePath, dataBase64) => { + api.files._entries.set(relativePath, { type: 'file', content: Buffer.from(dataBase64, 'base64').toString('binary') }); + }, readText: async (relativePath) => { if (String(relativePath).split('/')[0].toLowerCase() === '.verstak') { throw new Error('reserved-path: .verstak is internal');