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
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() {
return (
<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="text-2xl font-semibold text-accent">Bolt</div>
</div>
<div className="ml-auto">
<ClientOnly>{() => <OpenStackBlitz />}</ClientOnly>
</div>
</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';
const NODE_PADDING_LEFT = 12;
const DEFAULT_HIDDEN_FILES = [/\/node_modules\//];
const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\.next/, /\.astro/];
interface Props {
files?: FileMap;

View File

@@ -27,6 +27,7 @@ export class WorkbenchStore {
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
modifiedFiles = new Set<string>();
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;
}

View File

@@ -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 = () => {

View File

@@ -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",