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
26 changes: 13 additions & 13 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Take a look at how [Bolt uses the AI SDK](https://github.com/stackblitz/bolt.new
Before you begin, ensure you have the following installed:

- Node.js (v20.15.1)
- pnpm (v9.4.0)
- npm (v11.12.1)

## Setup

Expand All @@ -53,7 +53,7 @@ git clone https://github.com/stackblitz/bolt.new.git
2. Install dependencies:

```bash
pnpm install
npm install
```

3. Create a `.env.local` file in the root directory and add your Anthropic API key:
Expand All @@ -72,21 +72,21 @@ VITE_LOG_LEVEL=debug

## Available Scripts

- `pnpm run dev`: Starts the development server.
- `pnpm run build`: Builds the project.
- `pnpm run start`: Runs the built application locally using Wrangler Pages. This script uses `bindings.sh` to set up necessary bindings so you don't have to duplicate environment variables.
- `pnpm run preview`: Builds the project and then starts it locally, useful for testing the production build. Note, HTTP streaming currently doesn't work as expected with `wrangler pages dev`.
- `pnpm test`: Runs the test suite using Vitest.
- `pnpm run typecheck`: Runs TypeScript type checking.
- `pnpm run typegen`: Generates TypeScript types using Wrangler.
- `pnpm run deploy`: Builds the project and deploys it to Cloudflare Pages.
- `npm run dev`: Starts the development server.
- `npm run build`: Builds the project.
- `npm run start`: Runs the built application locally using Wrangler Pages. This script uses `bindings.sh` to set up necessary bindings so you don't have to duplicate environment variables.
- `npm run preview`: Builds the project and then starts it locally, useful for testing the production build. Note, HTTP streaming currently doesn't work as expected with `wrangler pages dev`.
- `npm test`: Runs the test suite using Vitest.
- `npm run typecheck`: Runs TypeScript type checking.
- `npm run typegen`: Generates TypeScript types using Wrangler.
- `npm run deploy`: Builds the project and deploys it to Cloudflare Pages.

## Development

To start the development server:

```bash
pnpm run dev
npm run dev
```

This will start the Remix Vite development server.
Expand All @@ -96,15 +96,15 @@ This will start the Remix Vite development server.
Run the test suite with:

```bash
pnpm test
npm test
```

## Deployment

To deploy the application to Cloudflare Pages:

```bash
pnpm run deploy
npm run deploy
```

Make sure you have the necessary permissions and Wrangler is correctly configured for your Cloudflare account.
29 changes: 28 additions & 1 deletion app/components/workbench/Workbench.client.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useStore } from '@nanostores/react';
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
import { computed } from 'nanostores';
import { memo, useCallback, useEffect } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import {
type OnChangeCallback as OnEditorChange,
Expand All @@ -10,6 +10,7 @@ import {
import { IconButton } from '~/components/ui/IconButton';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
import { Slider, type SliderOptions } from '~/components/ui/Slider';
import { downloadZipFile } from '~/lib/zip';
import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
Expand Down Expand Up @@ -62,6 +63,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView);
const [isDownloading, setIsDownloading] = useState(false);

const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
Expand Down Expand Up @@ -99,6 +101,27 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
workbenchStore.resetCurrentDocument();
}, []);

const onDownloadZip = useCallback(async () => {
setIsDownloading(true);

try {
const projectFiles = await workbenchStore.getDownloadableProjectFiles();

if (projectFiles.length === 0) {
toast.info('There are no project files to download yet');
return;
}

await downloadZipFile('project.zip', projectFiles);
toast.success('Project ZIP downloaded');
} catch (error) {
toast.error('Failed to create project ZIP');
console.error(error);
} finally {
setIsDownloading(false);
}
}, []);

return (
chatStarted && (
<motion.div
Expand All @@ -121,6 +144,10 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor">
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" />
<PanelHeaderButton className="mr-1 text-sm" disabled={isDownloading} onClick={onDownloadZip}>
<div className={classNames('i-ph:download-simple', { 'i-svg-spinners:90-ring-with-bg': isDownloading })} />
{isDownloading ? 'Preparing ZIP' : 'Download as ZIP'}
</PanelHeaderButton>
{selectedView === 'code' && (
<PanelHeaderButton
className="mr-1 text-sm"
Expand Down
47 changes: 47 additions & 0 deletions app/lib/stores/workbench.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores';
import * as nodePath from 'node:path';
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
import { ActionRunner } from '~/lib/runtime/action-runner';
import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser';
import { webcontainer } from '~/lib/webcontainer';
import type { ITerminal } from '~/types/terminal';
import { WORK_DIR, WORK_DIR_NAME } from '~/utils/constants';
import { unreachable } from '~/utils/unreachable';
import { EditorStore } from './editor';
import { FilesStore, type FileMap } from './files';
Expand All @@ -23,6 +25,8 @@ type Artifacts = MapStore<Record<string, ArtifactState>>;

export type WorkbenchViewType = 'code' | 'preview';

const textEncoder = new TextEncoder();

export class WorkbenchStore {
#previewsStore = new PreviewsStore(webcontainer);
#filesStore = new FilesStore(webcontainer);
Expand Down Expand Up @@ -210,6 +214,23 @@ export class WorkbenchStore {
this.#filesStore.resetFileModifications();
}

async getDownloadableProjectFiles() {
const container = await webcontainer;
const files = await readDirectoryRecursive(container, WORK_DIR);
const documents = this.#editorStore.documents.get();

for (const [filePath, document] of Object.entries(documents)) {
files.set(filePath, textEncoder.encode(document.value));
}

return Array.from(files.entries())
.sort(([filePathA], [filePathB]) => filePathA.localeCompare(filePathB))
.map(([filePath, content]) => ({
path: nodePath.posix.join(WORK_DIR_NAME, nodePath.posix.relative(WORK_DIR, filePath)),
content,
}));
}

abortAllActions() {
// TODO: what do we wanna do and how do we wanna recover from this?
}
Expand Down Expand Up @@ -274,3 +295,29 @@ export class WorkbenchStore {
}

export const workbenchStore = new WorkbenchStore();

async function readDirectoryRecursive(
container: Awaited<typeof webcontainer>,
directoryPath: string,
files: Map<string, Uint8Array> = new Map(),
) {
const entries = (await container.fs.readdir(directoryPath, {
withFileTypes: true,
})) as Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;

for (const entry of entries) {
const entryPath = nodePath.posix.join(directoryPath, entry.name);

if (entry.isDirectory()) {
await readDirectoryRecursive(container, entryPath, files);
continue;
}

if (entry.isFile()) {
const fileContent = await container.fs.readFile(entryPath);
files.set(entryPath, fileContent instanceof Uint8Array ? fileContent : new Uint8Array(fileContent));
}
}

return files;
}
26 changes: 26 additions & 0 deletions app/lib/zip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import JSZip from 'jszip';

interface ZipEntry {
path: string;
content: Uint8Array;
}

export async function downloadZipFile(fileName: string, entries: ZipEntry[]) {
const zip = new JSZip();

for (const entry of entries) {
zip.file(entry.path, entry.content);
}

const blob = await zip.generateAsync({ type: 'blob' });
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');

link.href = objectUrl;
link.download = fileName;
document.body.append(link);
link.click();
link.remove();

window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
}
Loading
Loading