feat: add 'Open in StackBlitz' button to header (#10)

This commit is contained in:
Connor Fogarty 2024-07-26 09:08:24 -05:00 committed by GitHub
parent 20e2d49993
commit d35f64eb1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 101 additions and 2 deletions

View File

@ -1,9 +1,15 @@
import { ClientOnly } from 'remix-utils/client-only';
import { OpenStackBlitz } from './OpenStackBlitz.client';
export function Header() { export function Header() {
return ( return (
<header className="flex items-center bg-white p-4 border-b border-gray-200 h-[var(--header-height)]"> <header className="flex items-center bg-white p-4 border-b border-gray-200 h-[var(--header-height)]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-2xl font-semibold text-accent">Bolt</div> <div className="text-2xl font-semibold text-accent">Bolt</div>
</div> </div>
<div className="ml-auto">
<ClientOnly>{() => <OpenStackBlitz />}</ClientOnly>
</div>
</header> </header>
); );
} }

View File

@ -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<string, ActionState>) => {
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<string, ActionState>) => 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 (
<a onClick={handleClick} className="cursor-pointer">
<img alt="Open in StackBlitz" src="https://developer.stackblitz.com/img/open_in_stackblitz.svg" />
</a>
);
});

View File

@ -4,7 +4,7 @@ import { classNames } from '~/utils/classNames';
import { renderLogger } from '~/utils/logger'; import { renderLogger } from '~/utils/logger';
const NODE_PADDING_LEFT = 12; const NODE_PADDING_LEFT = 12;
const DEFAULT_HIDDEN_FILES = [/\/node_modules\//]; const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\.next/, /\.astro/];
interface Props { interface Props {
files?: FileMap; files?: FileMap;

View File

@ -27,6 +27,7 @@ export class WorkbenchStore {
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false); showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>()); unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
modifiedFiles = new Set<string>(); modifiedFiles = new Set<string>();
artifactList: string[] = [];
constructor() { constructor() {
if (import.meta.hot) { if (import.meta.hot) {
@ -184,6 +185,7 @@ export class WorkbenchStore {
const artifact = this.#getArtifact(messageId); const artifact = this.#getArtifact(messageId);
if (artifact) { if (artifact) {
this.artifactList.push(messageId);
return; return;
} }

View File

@ -2,7 +2,7 @@ import { type LoaderFunctionArgs, type MetaFunction } from '@remix-run/cloudflar
import { ClientOnly } from 'remix-utils/client-only'; import { ClientOnly } from 'remix-utils/client-only';
import { BaseChat } from '~/components/chat/BaseChat'; import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client'; import { Chat } from '~/components/chat/Chat.client';
import { Header } from '~/components/Header'; import { Header } from '~/components/header/Header';
import { handleAuthRequest } from '~/lib/.server/login'; import { handleAuthRequest } from '~/lib/.server/login';
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {

View File

@ -37,6 +37,7 @@
"@remix-run/cloudflare": "^2.10.2", "@remix-run/cloudflare": "^2.10.2",
"@remix-run/cloudflare-pages": "^2.10.2", "@remix-run/cloudflare-pages": "^2.10.2",
"@remix-run/react": "^2.10.2", "@remix-run/react": "^2.10.2",
"@stackblitz/sdk": "^1.11.0",
"@unocss/reset": "^0.61.0", "@unocss/reset": "^0.61.0",
"@webcontainer/api": "^1.3.0-internal.1", "@webcontainer/api": "^1.3.0-internal.1",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",

View File

@ -98,6 +98,9 @@ importers:
'@remix-run/react': '@remix-run/react':
specifier: ^2.10.2 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) 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': '@unocss/reset':
specifier: ^0.61.0 specifier: ^0.61.0
version: 0.61.0 version: 0.61.0
@ -1427,6 +1430,9 @@ packages:
'@sinclair/typebox@0.27.8': '@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} 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': '@stylistic/eslint-plugin-js@2.3.0':
resolution: {integrity: sha512-lQwoiYb0Fs6Yc5QS3uT8+T9CPKK2Eoxc3H8EnYJgM26v/DgtW+1lvy2WNgyBflU+ThShZaHm3a6CdD9QeKx23w==} resolution: {integrity: sha512-lQwoiYb0Fs6Yc5QS3uT8+T9CPKK2Eoxc3H8EnYJgM26v/DgtW+1lvy2WNgyBflU+ThShZaHm3a6CdD9QeKx23w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -6342,6 +6348,8 @@ snapshots:
'@sinclair/typebox@0.27.8': {} '@sinclair/typebox@0.27.8': {}
'@stackblitz/sdk@1.11.0': {}
'@stylistic/eslint-plugin-js@2.3.0(eslint@9.5.0)': '@stylistic/eslint-plugin-js@2.3.0(eslint@9.5.0)':
dependencies: dependencies:
'@types/eslint': 8.56.10 '@types/eslint': 8.56.10