Connor Fogarty
commited on
feat: add 'Open in StackBlitz' button to header (#10)
Browse files- packages/bolt/app/components/{Header.tsx → header/Header.tsx} +6 -0
- packages/bolt/app/components/header/OpenStackBlitz.client.tsx +82 -0
- packages/bolt/app/components/workbench/FileTree.tsx +1 -1
- packages/bolt/app/lib/stores/workbench.ts +2 -0
- packages/bolt/app/routes/_index.tsx +1 -1
- packages/bolt/package.json +1 -0
- pnpm-lock.yaml +8 -0
packages/bolt/app/components/{Header.tsx → header/Header.tsx}
RENAMED
|
@@ -1,9 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export function Header() {
|
| 2 |
return (
|
| 3 |
<header className="flex items-center bg-white p-4 border-b border-gray-200 h-[var(--header-height)]">
|
| 4 |
<div className="flex items-center gap-2">
|
| 5 |
<div className="text-2xl font-semibold text-accent">Bolt</div>
|
| 6 |
</div>
|
|
|
|
|
|
|
|
|
|
| 7 |
</header>
|
| 8 |
);
|
| 9 |
}
|
|
|
|
| 1 |
+
import { ClientOnly } from 'remix-utils/client-only';
|
| 2 |
+
import { OpenStackBlitz } from './OpenStackBlitz.client';
|
| 3 |
+
|
| 4 |
export function Header() {
|
| 5 |
return (
|
| 6 |
<header className="flex items-center bg-white p-4 border-b border-gray-200 h-[var(--header-height)]">
|
| 7 |
<div className="flex items-center gap-2">
|
| 8 |
<div className="text-2xl font-semibold text-accent">Bolt</div>
|
| 9 |
</div>
|
| 10 |
+
<div className="ml-auto">
|
| 11 |
+
<ClientOnly>{() => <OpenStackBlitz />}</ClientOnly>
|
| 12 |
+
</div>
|
| 13 |
</header>
|
| 14 |
);
|
| 15 |
}
|
packages/bolt/app/components/header/OpenStackBlitz.client.tsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from 'path';
|
| 2 |
+
import { useStore } from '@nanostores/react';
|
| 3 |
+
import sdk from '@stackblitz/sdk';
|
| 4 |
+
import type { FileMap } from '~/lib/stores/files';
|
| 5 |
+
import { workbenchStore, type ArtifactState } from '~/lib/stores/workbench';
|
| 6 |
+
import { WORK_DIR } from '~/utils/constants';
|
| 7 |
+
import { memo, useCallback, useEffect, useState } from 'react';
|
| 8 |
+
import type { ActionState } from '~/lib/runtime/action-runner';
|
| 9 |
+
|
| 10 |
+
// return false if some file-writing actions haven't completed
|
| 11 |
+
const fileActionsComplete = (actions: Record<string, ActionState>) => {
|
| 12 |
+
return !Object.values(actions).some((action) => action.type === 'file' && action.status !== 'complete');
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
// extract relative path and content from file, wrapped in array for flatMap use
|
| 16 |
+
const extractContent = ([file, value]: [string, FileMap[string]]) => {
|
| 17 |
+
// ignore directory entries
|
| 18 |
+
if (!value || value.type !== 'file') {
|
| 19 |
+
return [];
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const relative = path.relative(WORK_DIR, file);
|
| 23 |
+
const parts = relative.split(path.sep);
|
| 24 |
+
|
| 25 |
+
// ignore hidden files
|
| 26 |
+
if (parts.some((part) => part.startsWith('.'))) {
|
| 27 |
+
return [];
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
return [[relative, value.content]];
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
// subscribe to changes in first artifact's runner actions
|
| 34 |
+
const useFirstArtifact = (): [boolean, ArtifactState] => {
|
| 35 |
+
const [hasLoaded, setHasLoaded] = useState(false);
|
| 36 |
+
const artifacts = useStore(workbenchStore.artifacts);
|
| 37 |
+
const firstArtifact = artifacts[workbenchStore.artifactList[0]];
|
| 38 |
+
|
| 39 |
+
const handleActionChange = useCallback(
|
| 40 |
+
(actions: Record<string, ActionState>) => setHasLoaded(fileActionsComplete(actions)),
|
| 41 |
+
[firstArtifact],
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
if (firstArtifact) {
|
| 46 |
+
return firstArtifact.runner.actions.subscribe(handleActionChange);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return undefined;
|
| 50 |
+
}, [firstArtifact]);
|
| 51 |
+
|
| 52 |
+
return [hasLoaded, firstArtifact];
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
export const OpenStackBlitz = memo(() => {
|
| 56 |
+
const [artifactLoaded, artifact] = useFirstArtifact();
|
| 57 |
+
|
| 58 |
+
const handleClick = useCallback(() => {
|
| 59 |
+
// extract relative path and content from files map
|
| 60 |
+
const workbenchFiles = workbenchStore.files.get();
|
| 61 |
+
const files = Object.fromEntries(Object.entries(workbenchFiles).flatMap(extractContent));
|
| 62 |
+
|
| 63 |
+
// we use the first artifact's title for the StackBlitz project
|
| 64 |
+
const { title } = artifact;
|
| 65 |
+
|
| 66 |
+
sdk.openProject({
|
| 67 |
+
title,
|
| 68 |
+
template: 'node',
|
| 69 |
+
files,
|
| 70 |
+
});
|
| 71 |
+
}, [artifact]);
|
| 72 |
+
|
| 73 |
+
if (!artifactLoaded) {
|
| 74 |
+
return null;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<a onClick={handleClick} className="cursor-pointer">
|
| 79 |
+
<img alt="Open in StackBlitz" src="https://developer.stackblitz.com/img/open_in_stackblitz.svg" />
|
| 80 |
+
</a>
|
| 81 |
+
);
|
| 82 |
+
});
|
packages/bolt/app/components/workbench/FileTree.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { classNames } from '~/utils/classNames';
|
|
| 4 |
import { renderLogger } from '~/utils/logger';
|
| 5 |
|
| 6 |
const NODE_PADDING_LEFT = 12;
|
| 7 |
-
const DEFAULT_HIDDEN_FILES = [/\/node_modules
|
| 8 |
|
| 9 |
interface Props {
|
| 10 |
files?: FileMap;
|
|
|
|
| 4 |
import { renderLogger } from '~/utils/logger';
|
| 5 |
|
| 6 |
const NODE_PADDING_LEFT = 12;
|
| 7 |
+
const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\.next/, /\.astro/];
|
| 8 |
|
| 9 |
interface Props {
|
| 10 |
files?: FileMap;
|
packages/bolt/app/lib/stores/workbench.ts
CHANGED
|
@@ -27,6 +27,7 @@ export class WorkbenchStore {
|
|
| 27 |
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
|
| 28 |
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
|
| 29 |
modifiedFiles = new Set<string>();
|
|
|
|
| 30 |
|
| 31 |
constructor() {
|
| 32 |
if (import.meta.hot) {
|
|
@@ -184,6 +185,7 @@ export class WorkbenchStore {
|
|
| 184 |
const artifact = this.#getArtifact(messageId);
|
| 185 |
|
| 186 |
if (artifact) {
|
|
|
|
| 187 |
return;
|
| 188 |
}
|
| 189 |
|
|
|
|
| 27 |
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
|
| 28 |
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
|
| 29 |
modifiedFiles = new Set<string>();
|
| 30 |
+
artifactList: string[] = [];
|
| 31 |
|
| 32 |
constructor() {
|
| 33 |
if (import.meta.hot) {
|
|
|
|
| 185 |
const artifact = this.#getArtifact(messageId);
|
| 186 |
|
| 187 |
if (artifact) {
|
| 188 |
+
this.artifactList.push(messageId);
|
| 189 |
return;
|
| 190 |
}
|
| 191 |
|
packages/bolt/app/routes/_index.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { type LoaderFunctionArgs, type MetaFunction } from '@remix-run/cloudflar
|
|
| 2 |
import { ClientOnly } from 'remix-utils/client-only';
|
| 3 |
import { BaseChat } from '~/components/chat/BaseChat';
|
| 4 |
import { Chat } from '~/components/chat/Chat.client';
|
| 5 |
-
import { Header } from '~/components/Header';
|
| 6 |
import { handleAuthRequest } from '~/lib/.server/login';
|
| 7 |
|
| 8 |
export const meta: MetaFunction = () => {
|
|
|
|
| 2 |
import { ClientOnly } from 'remix-utils/client-only';
|
| 3 |
import { BaseChat } from '~/components/chat/BaseChat';
|
| 4 |
import { Chat } from '~/components/chat/Chat.client';
|
| 5 |
+
import { Header } from '~/components/header/Header';
|
| 6 |
import { handleAuthRequest } from '~/lib/.server/login';
|
| 7 |
|
| 8 |
export const meta: MetaFunction = () => {
|
packages/bolt/package.json
CHANGED
|
@@ -37,6 +37,7 @@
|
|
| 37 |
"@remix-run/cloudflare": "^2.10.2",
|
| 38 |
"@remix-run/cloudflare-pages": "^2.10.2",
|
| 39 |
"@remix-run/react": "^2.10.2",
|
|
|
|
| 40 |
"@unocss/reset": "^0.61.0",
|
| 41 |
"@webcontainer/api": "^1.3.0-internal.1",
|
| 42 |
"@xterm/addon-fit": "^0.10.0",
|
|
|
|
| 37 |
"@remix-run/cloudflare": "^2.10.2",
|
| 38 |
"@remix-run/cloudflare-pages": "^2.10.2",
|
| 39 |
"@remix-run/react": "^2.10.2",
|
| 40 |
+
"@stackblitz/sdk": "^1.11.0",
|
| 41 |
"@unocss/reset": "^0.61.0",
|
| 42 |
"@webcontainer/api": "^1.3.0-internal.1",
|
| 43 |
"@xterm/addon-fit": "^0.10.0",
|
pnpm-lock.yaml
CHANGED
|
@@ -98,6 +98,9 @@ importers:
|
|
| 98 |
'@remix-run/react':
|
| 99 |
specifier: ^2.10.2
|
| 100 |
version: 2.10.2([email protected]([email protected]))([email protected])([email protected])
|
|
|
|
|
|
|
|
|
|
| 101 |
'@unocss/reset':
|
| 102 |
specifier: ^0.61.0
|
| 103 |
version: 0.61.0
|
|
@@ -1427,6 +1430,9 @@ packages:
|
|
| 1427 |
'@sinclair/[email protected]':
|
| 1428 |
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
|
| 1429 |
|
|
|
|
|
|
|
|
|
|
| 1430 |
'@stylistic/[email protected]':
|
| 1431 |
resolution: {integrity: sha512-lQwoiYb0Fs6Yc5QS3uT8+T9CPKK2Eoxc3H8EnYJgM26v/DgtW+1lvy2WNgyBflU+ThShZaHm3a6CdD9QeKx23w==}
|
| 1432 |
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
|
@@ -6342,6 +6348,8 @@ snapshots:
|
|
| 6342 |
|
| 6343 |
'@sinclair/[email protected]': {}
|
| 6344 |
|
|
|
|
|
|
|
| 6345 |
'@stylistic/[email protected]([email protected])':
|
| 6346 |
dependencies:
|
| 6347 |
'@types/eslint': 8.56.10
|
|
|
|
| 98 |
'@remix-run/react':
|
| 99 |
specifier: ^2.10.2
|
| 100 |
version: 2.10.2([email protected]([email protected]))([email protected])([email protected])
|
| 101 |
+
'@stackblitz/sdk':
|
| 102 |
+
specifier: ^1.11.0
|
| 103 |
+
version: 1.11.0
|
| 104 |
'@unocss/reset':
|
| 105 |
specifier: ^0.61.0
|
| 106 |
version: 0.61.0
|
|
|
|
| 1430 |
'@sinclair/[email protected]':
|
| 1431 |
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
|
| 1432 |
|
| 1433 |
+
'@stackblitz/[email protected]':
|
| 1434 |
+
resolution: {integrity: sha512-DFQGANNkEZRzFk1/rDP6TcFdM82ycHE+zfl9C/M/jXlH68jiqHWHFMQURLELoD8koxvu/eW5uhg94NSAZlYrUQ==}
|
| 1435 |
+
|
| 1436 |
'@stylistic/[email protected]':
|
| 1437 |
resolution: {integrity: sha512-lQwoiYb0Fs6Yc5QS3uT8+T9CPKK2Eoxc3H8EnYJgM26v/DgtW+1lvy2WNgyBflU+ThShZaHm3a6CdD9QeKx23w==}
|
| 1438 |
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
|
|
|
| 6348 |
|
| 6349 |
'@sinclair/[email protected]': {}
|
| 6350 |
|
| 6351 |
+
'@stackblitz/[email protected]': {}
|
| 6352 |
+
|
| 6353 |
'@stylistic/[email protected]([email protected])':
|
| 6354 |
dependencies:
|
| 6355 |
'@types/eslint': 8.56.10
|