From d35f64eb1d6c45b527dfb321d14f184cd293fbf5 Mon Sep 17 00:00:00 2001
From: Connor Fogarty <40175891+connorff@users.noreply.github.com>
Date: Fri, 26 Jul 2024 09:08:24 -0500
Subject: [PATCH] feat: add 'Open in StackBlitz' button to header (#10)
---
.../app/components/{ => header}/Header.tsx | 6 ++
.../header/OpenStackBlitz.client.tsx | 82 +++++++++++++++++++
.../app/components/workbench/FileTree.tsx | 2 +-
packages/bolt/app/lib/stores/workbench.ts | 2 +
packages/bolt/app/routes/_index.tsx | 2 +-
packages/bolt/package.json | 1 +
pnpm-lock.yaml | 8 ++
7 files changed, 101 insertions(+), 2 deletions(-)
rename packages/bolt/app/components/{ => header}/Header.tsx (57%)
create mode 100644 packages/bolt/app/components/header/OpenStackBlitz.client.tsx
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 (
);
}
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 (
+
+
+
+ );
+});
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