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
18 changes: 18 additions & 0 deletions app/components/chat/Chat.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
109 changes: 109 additions & 0 deletions app/components/chat/ImportZipButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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<void>;
}

export const ImportZipButton: React.FC<ImportZipButtonProps> = ({ className, importChat }) => {
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
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.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.');
}

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 (
<>
<input ref={inputRef} type="file" className="hidden" accept=".zip" onChange={handleFileChange} />
<Button
onClick={() => inputRef.current?.click()}
title="Import ZIP"
variant="default"
size="lg"
className={classNames(
'gap-2 bg-bolt-elements-background-depth-1',
'text-bolt-elements-textPrimary',
'hover:bg-bolt-elements-background-depth-2',
'border border-bolt-elements-borderColor',
'h-10 px-4 py-2 min-w-[120px] justify-center',
'transition-all duration-200 ease-in-out',
className,
)}
disabled={isLoading}
>
<span className="i-ph:file-zip w-4 h-4" />
{isLoading ? 'Importing…' : 'Import ZIP'}
</Button>
</>
);
};
12 changes: 12 additions & 0 deletions app/components/chat/chatExportAndImport/ImportButtons.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -89,6 +90,17 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
'transition-all duration-200 ease-in-out rounded-lg',
)}
/>
<ImportZipButton
importChat={importChat}
className={classNames(
'gap-2 bg-bolt-elements-background-depth-1',
'text-bolt-elements-textPrimary',
'hover:bg-bolt-elements-background-depth-2',
'border border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]',
'h-10 px-4 py-2 min-w-[120px] justify-center',
'transition-all duration-200 ease-in-out rounded-lg',
)}
/>
</div>
</div>
</div>
Expand Down
202 changes: 202 additions & 0 deletions app/utils/zipImport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
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;
hasExpoConfig: 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<ZipImportResult> => {
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'));

/*
* 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 =
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}

<boltArtifact id="imported-files" title="Imported Files" type="bundled">
${fileArtifacts
.map(
(file) => `<boltAction type="file" filePath="${file.path}">
${escapeBoltTags(file.content)}
</boltAction>`,
)
.join('\n\n')}
</boltArtifact>`,
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,
hasExpoConfig,
};
};
Loading