From 21acda35f1b2bb1e4393f04a82f9f0627dddc45a Mon Sep 17 00:00:00 2001 From: Aaron Smith Date: Sun, 12 Apr 2026 12:42:17 -0600 Subject: [PATCH 1/2] feat: add ZIP archive import button --- app/components/chat/Chat.client.tsx | 18 ++ app/components/chat/ImportZipButton.tsx | 100 +++++++++++ .../chatExportAndImport/ImportButtons.tsx | 12 ++ app/utils/zipImport.ts | 155 ++++++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 app/components/chat/ImportZipButton.tsx create mode 100644 app/utils/zipImport.ts diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index ccddaf51d6..f5fe7b3087 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -200,6 +200,24 @@ export const ChatImpl = memo( chatStore.setKey('started', initialMessages.length > 0); }, []); + /* + * Pre-fill the textarea with the follow-up prompt set by ImportZipButton before the + * full-page navigation. Using setInput instead of append so the user confirms with + * one Enter keystroke — avoids model-state race conditions on chat initialisation. + */ + useEffect(() => { + if (initialMessages.length === 0) { + return; + } + + const autorun = localStorage.getItem('bolt_zip_autorun'); + + if (autorun) { + localStorage.removeItem('bolt_zip_autorun'); + setInput(autorun); + } + }, []); + useEffect(() => { processSampledMessages({ messages, diff --git a/app/components/chat/ImportZipButton.tsx b/app/components/chat/ImportZipButton.tsx new file mode 100644 index 0000000000..a2ea228e4c --- /dev/null +++ b/app/components/chat/ImportZipButton.tsx @@ -0,0 +1,100 @@ +import React, { useRef, useState } from 'react'; +import type { Message } from 'ai'; +import { toast } from 'react-toastify'; +import { createChatFromZip } from '~/utils/zipImport'; +import { logStore } from '~/lib/stores/logs'; +import { Button } from '~/components/ui/Button'; +import { classNames } from '~/utils/classNames'; + +interface ImportZipButtonProps { + className?: string; + importChat?: (description: string, messages: Message[]) => Promise; +} + +export const ImportZipButton: React.FC = ({ className, importChat }) => { + const [isLoading, setIsLoading] = useState(false); + const inputRef = useRef(null); + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + + if (!file) { + return; + } + + setIsLoading(true); + + const loadingToast = toast.loading(`Importing ${file.name}…`); + + try { + const result = await createChatFromZip(file); + + if (result.skippedBinary > 0) { + logStore.logWarning('Skipping binary files during ZIP import', { + zipName: file.name, + binaryCount: result.skippedBinary, + }); + toast.info(`Skipping ${result.skippedBinary} binary file${result.skippedBinary === 1 ? '' : 's'}`); + } + + /* + * Set flag before navigation so the new chat picks it up on mount. + * importChat does a full window.location.href redirect, so append() + * would be gone by the time it resolves. + */ + if (result.hasPackageJson) { + localStorage.setItem('bolt_zip_autorun', 'Install the dependencies and start the development server.'); + } + + if (importChat) { + await importChat(file.name.replace(/\.zip$/i, ''), result.messages); + } + + logStore.logSystem('ZIP imported successfully', { + zipName: file.name, + textFileCount: result.totalFiles - result.skippedBinary - result.skippedIgnored, + binaryFileCount: result.skippedBinary, + ignoredFileCount: result.skippedIgnored, + }); + + toast.success('ZIP imported successfully'); + } catch (error) { + logStore.logError('Failed to import ZIP', error, { zipName: file.name }); + console.error('Failed to import ZIP:', error); + toast.error(error instanceof Error ? error.message : 'Failed to import ZIP'); + } finally { + setIsLoading(false); + toast.dismiss(loadingToast); + + // Reset so the same file can be re-selected + if (inputRef.current) { + inputRef.current.value = ''; + } + } + }; + + return ( + <> + + + + ); +}; diff --git a/app/components/chat/chatExportAndImport/ImportButtons.tsx b/app/components/chat/chatExportAndImport/ImportButtons.tsx index c183558714..92c9f46c90 100644 --- a/app/components/chat/chatExportAndImport/ImportButtons.tsx +++ b/app/components/chat/chatExportAndImport/ImportButtons.tsx @@ -1,6 +1,7 @@ import type { Message } from 'ai'; import { toast } from 'react-toastify'; import { ImportFolderButton } from '~/components/chat/ImportFolderButton'; +import { ImportZipButton } from '~/components/chat/ImportZipButton'; import { Button } from '~/components/ui/Button'; import { classNames } from '~/utils/classNames'; @@ -89,6 +90,17 @@ export function ImportButtons(importChat: ((description: string, messages: Messa 'transition-all duration-200 ease-in-out rounded-lg', )} /> + diff --git a/app/utils/zipImport.ts b/app/utils/zipImport.ts new file mode 100644 index 0000000000..1c0df51cf4 --- /dev/null +++ b/app/utils/zipImport.ts @@ -0,0 +1,155 @@ +import type { Message } from 'ai'; +import JSZip from 'jszip'; +import { generateId, shouldIncludeFile } from './fileUtils'; +import { escapeBoltTags } from './projectCommands'; + +/** + * Checks whether a Uint8Array looks like a binary file. + * Mirrors the logic in isBinaryFile (fileUtils.ts) but works directly + * with raw bytes instead of a browser File object. + */ +function isBinaryBuffer(buffer: Uint8Array): boolean { + const checkLength = Math.min(buffer.length, 1024); + + for (let i = 0; i < checkLength; i++) { + const byte = buffer[i]; + + if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) { + return true; + } + } + + return false; +} + +/** + * Detects and returns a common root prefix that all file paths share + * (e.g. "my-project/" when macOS Compress or GitHub wraps everything + * in a top-level folder). Returns an empty string if there is no such + * prefix, or if the prefix would consume all path segments. + */ +function detectRootPrefix(paths: string[]): string { + if (paths.length === 0) { + return ''; + } + + const firstSegment = paths[0].split('/')[0] + '/'; + const allSharePrefix = paths.every((p) => p.startsWith(firstSegment)); + + // Make sure the prefix is not the entire path of every file + const prefixIsWholeFile = paths.every((p) => p === firstSegment || p === firstSegment.slice(0, -1)); + + if (allSharePrefix && !prefixIsWholeFile && firstSegment !== '/') { + return firstSegment; + } + + return ''; +} + +export interface ZipImportResult { + messages: Message[]; + skippedBinary: number; + skippedIgnored: number; + totalFiles: number; + hasPackageJson: boolean; +} + +/** + * Reads a ZIP file and converts its contents into the same boltArtifact + * chat-message structure that createChatFromFolder produces. This lets + * bolt load the project correctly without writing directly to the + * WebContainer filesystem (which can be wiped by navigation resets). + */ +export const createChatFromZip = async (zipFile: File): Promise => { + const zip = new JSZip(); + const contents = await zip.loadAsync(zipFile); + + // Collect all non-directory entries + const allEntries = Object.entries(contents.files).filter(([, entry]) => !entry.dir); + + if (allEntries.length === 0) { + throw new Error('The ZIP file contains no files.'); + } + + // Strip common root prefix (e.g. "my-project/" from macOS Compress) + const rawPaths = allEntries.map(([path]) => path); + const prefix = detectRootPrefix(rawPaths); + + // Process every entry: resolve path, filter, read bytes + const fileArtifacts: Array<{ path: string; content: string }> = []; + const binaryFilePaths: string[] = []; + let skippedIgnored = 0; + + for (const [rawPath, zipEntry] of allEntries) { + const relativePath = prefix ? rawPath.slice(prefix.length) : rawPath; + + // Skip empty paths that arise when stripping the prefix of the root dir entry itself + if (!relativePath) { + continue; + } + + // Apply the same ignore rules as ImportFolderButton + if (!shouldIncludeFile(relativePath)) { + skippedIgnored++; + continue; + } + + const buffer = await zipEntry.async('uint8array'); + + if (isBinaryBuffer(buffer)) { + binaryFilePaths.push(relativePath); + continue; + } + + const content = new TextDecoder('utf-8', { fatal: false }).decode(buffer); + fileArtifacts.push({ path: relativePath, content }); + } + + if (fileArtifacts.length === 0) { + throw new Error('No readable text files found in the ZIP (all files were binary or ignored).'); + } + + // Detect whether this is a Node project so we can craft the follow-up prompt + const hasPackageJson = fileArtifacts.some((f) => f.path === 'package.json' || f.path.endsWith('/package.json')); + + const folderName = zipFile.name.replace(/\.zip$/i, ''); + + const binaryFilesMessage = + binaryFilePaths.length > 0 + ? `\n\nSkipped ${binaryFilePaths.length} binary file${binaryFilePaths.length === 1 ? '' : 's'}:\n${binaryFilePaths.map((f) => `- ${f}`).join('\n')}` + : ''; + + const filesMessage: Message = { + role: 'assistant', + content: `I've imported the contents of the "${folderName}" ZIP archive.${binaryFilesMessage} + + +${fileArtifacts + .map( + (file) => ` +${escapeBoltTags(file.content)} +`, + ) + .join('\n\n')} +`, + id: generateId(), + createdAt: new Date(), + }; + + const userMessage: Message = { + role: 'user', + id: generateId(), + content: `Import the "${folderName}" project from ZIP`, + createdAt: new Date(), + }; + + const messages: Message[] = [userMessage, filesMessage]; + + return { + messages, + skippedBinary: binaryFilePaths.length, + skippedIgnored, + totalFiles: fileArtifacts.length + binaryFilePaths.length + skippedIgnored, + hasPackageJson, + }; +}; From efe786f63c8a9d9b00281923e906809a9c47e474 Mon Sep 17 00:00:00 2001 From: Aaron Smith Date: Mon, 13 Apr 2026 10:45:46 -0600 Subject: [PATCH 2/2] feat(zip-import): detect Expo projects and skip dev server autorun - Add hasExpoConfig detection in zipImport.ts (checks app.json, app.config.js, and expo key in package.json dependencies) - Strip runnable scripts from package.json for Expo imports so bolt cannot attempt to boot a native project in WebContainer - Show code review prompt for Expo imports instead of install+start command --- app/components/chat/ImportZipButton.tsx | 11 +++++- app/utils/zipImport.ts | 47 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/app/components/chat/ImportZipButton.tsx b/app/components/chat/ImportZipButton.tsx index a2ea228e4c..fce033fc6f 100644 --- a/app/components/chat/ImportZipButton.tsx +++ b/app/components/chat/ImportZipButton.tsx @@ -42,7 +42,16 @@ export const ImportZipButton: React.FC = ({ className, imp * importChat does a full window.location.href redirect, so append() * would be gone by the time it resolves. */ - if (result.hasPackageJson) { + if (result.hasExpoConfig) { + /* + * Expo/React Native — WebContainer can't run native code. + * Ask bolt to review the code instead of trying to boot the project. + */ + localStorage.setItem( + 'bolt_zip_autorun', + 'This is an Expo/React Native project. Review the code structure and give me a summary of what the app does and how it is organized. Do not run any install or dev server commands — I will run this locally with Expo CLI.', + ); + } else if (result.hasPackageJson) { localStorage.setItem('bolt_zip_autorun', 'Install the dependencies and start the development server.'); } diff --git a/app/utils/zipImport.ts b/app/utils/zipImport.ts index 1c0df51cf4..9313fb8954 100644 --- a/app/utils/zipImport.ts +++ b/app/utils/zipImport.ts @@ -52,6 +52,7 @@ export interface ZipImportResult { skippedIgnored: number; totalFiles: number; hasPackageJson: boolean; + hasExpoConfig: boolean; } /** @@ -112,6 +113,51 @@ export const createChatFromZip = async (zipFile: File): Promise // Detect whether this is a Node project so we can craft the follow-up prompt const hasPackageJson = fileArtifacts.some((f) => f.path === 'package.json' || f.path.endsWith('/package.json')); + /* + * Detect Expo/React Native projects so we can suppress the "npm install + * and start dev server" prompt — WebContainer can't run native mobile code. + * We check for app.json / app.config.js (Expo-specific config files) or + * an "expo" key in the package.json dependencies. + */ + const hasExpoConfig = + fileArtifacts.some((f) => f.path === 'app.json' || f.path === 'app.config.js' || f.path === 'app.config.ts') || + fileArtifacts.some((f) => { + if (f.path !== 'package.json') { + return false; + } + + try { + const pkg = JSON.parse(f.content); + return !!(pkg.dependencies?.expo || pkg.devDependencies?.expo); + } catch { + return false; + } + }); + + /* + * For Expo projects, strip the scripts from package.json so bolt has + * nothing to execute even if it tries. The user will run the project + * locally with Expo CLI. We preserve the rest of package.json intact. + */ + if (hasExpoConfig) { + const pkgIndex = fileArtifacts.findIndex((f) => f.path === 'package.json'); + + if (pkgIndex !== -1) { + try { + const pkg = JSON.parse(fileArtifacts[pkgIndex].content); + pkg.scripts = { + _note: 'Run this project locally: npx expo start', + }; + fileArtifacts[pkgIndex] = { + ...fileArtifacts[pkgIndex], + content: JSON.stringify(pkg, null, 2), + }; + } catch { + /* If package.json is unparseable, leave it as-is */ + } + } + } + const folderName = zipFile.name.replace(/\.zip$/i, ''); const binaryFilesMessage = @@ -151,5 +197,6 @@ ${escapeBoltTags(file.content)} skippedIgnored, totalFiles: fileArtifacts.length + binaryFilePaths.length + skippedIgnored, hasPackageJson, + hasExpoConfig, }; };