Connor Fogarty commited on
Commit
d35f64e
·
unverified ·
1 Parent(s): 20e2d49

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

Browse files
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
 
 
 
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
6346
  dependencies:
6347
  '@types/eslint': 8.56.10
 
98
  '@remix-run/react':
99
  specifier: ^2.10.2
100
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
6354
  dependencies:
6355
  '@types/eslint': 8.56.10