diff --git a/packages/bolt/app/components/Header.tsx b/packages/bolt/app/components/header/Header.tsx similarity index 57% rename from packages/bolt/app/components/Header.tsx rename to packages/bolt/app/components/header/Header.tsx index ca67ab6..f51d861 100644 --- a/packages/bolt/app/components/Header.tsx +++ b/packages/bolt/app/components/header/Header.tsx @@ -1,9 +1,15 @@ +import { ClientOnly } from 'remix-utils/client-only'; +import { OpenStackBlitz } from './OpenStackBlitz.client'; + export function Header() { return (
Bolt
+
+ {() => } +
); } diff --git a/packages/bolt/app/components/header/OpenStackBlitz.client.tsx b/packages/bolt/app/components/header/OpenStackBlitz.client.tsx new file mode 100644 index 0000000..25deb06 --- /dev/null +++ b/packages/bolt/app/components/header/OpenStackBlitz.client.tsx @@ -0,0 +1,82 @@ +import path from 'path'; +import { useStore } from '@nanostores/react'; +import sdk from '@stackblitz/sdk'; +import type { FileMap } from '~/lib/stores/files'; +import { workbenchStore, type ArtifactState } from '~/lib/stores/workbench'; +import { WORK_DIR } from '~/utils/constants'; +import { memo, useCallback, useEffect, useState } from 'react'; +import type { ActionState } from '~/lib/runtime/action-runner'; + +// return false if some file-writing actions haven't completed +const fileActionsComplete = (actions: Record) => { + return !Object.values(actions).some((action) => action.type === 'file' && action.status !== 'complete'); +}; + +// extract relative path and content from file, wrapped in array for flatMap use +const extractContent = ([file, value]: [string, FileMap[string]]) => { + // ignore directory entries + if (!value || value.type !== 'file') { + return []; + } + + const relative = path.relative(WORK_DIR, file); + const parts = relative.split(path.sep); + + // ignore hidden files + if (parts.some((part) => part.startsWith('.'))) { + return []; + } + + return [[relative, value.content]]; +}; + +// subscribe to changes in first artifact's runner actions +const useFirstArtifact = (): [boolean, ArtifactState] => { + const [hasLoaded, setHasLoaded] = useState(false); + const artifacts = useStore(workbenchStore.artifacts); + const firstArtifact = artifacts[workbenchStore.artifactList[0]]; + + const handleActionChange = useCallback( + (actions: Record) => setHasLoaded(fileActionsComplete(actions)), + [firstArtifact], + ); + + useEffect(() => { + if (firstArtifact) { + return firstArtifact.runner.actions.subscribe(handleActionChange); + } + + return undefined; + }, [firstArtifact]); + + return [hasLoaded, firstArtifact]; +}; + +export const OpenStackBlitz = memo(() => { + const [artifactLoaded, artifact] = useFirstArtifact(); + + const handleClick = useCallback(() => { + // extract relative path and content from files map + const workbenchFiles = workbenchStore.files.get(); + const files = Object.fromEntries(Object.entries(workbenchFiles).flatMap(extractContent)); + + // we use the first artifact's title for the StackBlitz project + const { title } = artifact; + + sdk.openProject({ + title, + template: 'node', + files, + }); + }, [artifact]); + + if (!artifactLoaded) { + return null; + } + + return ( + + Open in StackBlitz + + ); +}); diff --git a/packages/bolt/app/components/workbench/FileTree.tsx b/packages/bolt/app/components/workbench/FileTree.tsx index 7d7d237..8206171 100644 --- a/packages/bolt/app/components/workbench/FileTree.tsx +++ b/packages/bolt/app/components/workbench/FileTree.tsx @@ -4,7 +4,7 @@ import { classNames } from '~/utils/classNames'; import { renderLogger } from '~/utils/logger'; const NODE_PADDING_LEFT = 12; -const DEFAULT_HIDDEN_FILES = [/\/node_modules\//]; +const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\.next/, /\.astro/]; interface Props { files?: FileMap; diff --git a/packages/bolt/app/lib/stores/workbench.ts b/packages/bolt/app/lib/stores/workbench.ts index b52233c..8603fe9 100644 --- a/packages/bolt/app/lib/stores/workbench.ts +++ b/packages/bolt/app/lib/stores/workbench.ts @@ -27,6 +27,7 @@ export class WorkbenchStore { showWorkbench: WritableAtom = import.meta.hot?.data.showWorkbench ?? atom(false); unsavedFiles: WritableAtom> = import.meta.hot?.data.unsavedFiles ?? atom(new Set()); modifiedFiles = new Set(); + artifactList: string[] = []; constructor() { if (import.meta.hot) { @@ -184,6 +185,7 @@ export class WorkbenchStore { const artifact = this.#getArtifact(messageId); if (artifact) { + this.artifactList.push(messageId); return; } diff --git a/packages/bolt/app/routes/_index.tsx b/packages/bolt/app/routes/_index.tsx index 62aabc9..e9a1f64 100644 --- a/packages/bolt/app/routes/_index.tsx +++ b/packages/bolt/app/routes/_index.tsx @@ -2,7 +2,7 @@ import { type LoaderFunctionArgs, type MetaFunction } from '@remix-run/cloudflar import { ClientOnly } from 'remix-utils/client-only'; import { BaseChat } from '~/components/chat/BaseChat'; import { Chat } from '~/components/chat/Chat.client'; -import { Header } from '~/components/Header'; +import { Header } from '~/components/header/Header'; import { handleAuthRequest } from '~/lib/.server/login'; export const meta: MetaFunction = () => { diff --git a/packages/bolt/package.json b/packages/bolt/package.json index fae8ce8..1511b89 100644 --- a/packages/bolt/package.json +++ b/packages/bolt/package.json @@ -37,6 +37,7 @@ "@remix-run/cloudflare": "^2.10.2", "@remix-run/cloudflare-pages": "^2.10.2", "@remix-run/react": "^2.10.2", + "@stackblitz/sdk": "^1.11.0", "@unocss/reset": "^0.61.0", "@webcontainer/api": "^1.3.0-internal.1", "@xterm/addon-fit": "^0.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19b4115..ce43c08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: '@remix-run/react': specifier: ^2.10.2 version: 2.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.2) + '@stackblitz/sdk': + specifier: ^1.11.0 + version: 1.11.0 '@unocss/reset': specifier: ^0.61.0 version: 0.61.0 @@ -1427,6 +1430,9 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@stackblitz/sdk@1.11.0': + resolution: {integrity: sha512-DFQGANNkEZRzFk1/rDP6TcFdM82ycHE+zfl9C/M/jXlH68jiqHWHFMQURLELoD8koxvu/eW5uhg94NSAZlYrUQ==} + '@stylistic/eslint-plugin-js@2.3.0': resolution: {integrity: sha512-lQwoiYb0Fs6Yc5QS3uT8+T9CPKK2Eoxc3H8EnYJgM26v/DgtW+1lvy2WNgyBflU+ThShZaHm3a6CdD9QeKx23w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6342,6 +6348,8 @@ snapshots: '@sinclair/typebox@0.27.8': {} + '@stackblitz/sdk@1.11.0': {} + '@stylistic/eslint-plugin-js@2.3.0(eslint@9.5.0)': dependencies: '@types/eslint': 8.56.10