@tanstack/cta-ui-base 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ const ALS_RESOLVER = `resolve: {
2
+ alias: {
3
+ 'node:async_hooks': '\\0virtual:async_hooks',
4
+ async_hooks: '\\0virtual:async_hooks',
5
+ },
6
+ },`;
7
+ const ALS_SHIM = `export class AsyncLocalStorage {
8
+ constructor() {
9
+ // queue: array of { store, fn, resolve, reject }
10
+ this._queue = [];
11
+ this._running = false; // true while processing queue
12
+ this._currentStore = undefined; // store visible to getStore() while a run executes
13
+ }
14
+
15
+ /**
16
+ * run(store, callback, ...args) -> Promise
17
+ * Queues the callback to run with store as the current store. If the callback
18
+ * returns a Promise, the queue waits for it to settle before starting the next run.
19
+ */
20
+ run(store, callback, ...args) {
21
+ return new Promise((resolve, reject) => {
22
+ this._queue.push({
23
+ store,
24
+ fn: () => callback(...args),
25
+ resolve,
26
+ reject,
27
+ });
28
+ // start processing (if not already)
29
+ this._processQueue().catch((err) => {
30
+ // _processQueue shouldn't throw; but guard anyway.
31
+ console.error('SerialAsyncLocalStorage internal error:', err);
32
+ });
33
+ });
34
+ }
35
+
36
+ /**
37
+ * getStore() -> current store or undefined
38
+ * Returns the store of the currently executing run (or undefined if none).
39
+ */
40
+ getStore() {
41
+ return this._currentStore;
42
+ }
43
+
44
+ /**
45
+ * enterWith(store)
46
+ * Set the current store for the currently running task synchronously.
47
+ * Throws if there is no active run (this polyfill requires you to be inside a run).
48
+ */
49
+ enterWith(store) {
50
+ if (!this._running) {
51
+ throw new Error('enterWith() may be used only while a run is active.');
52
+ }
53
+ this._currentStore = store;
54
+ }
55
+
56
+ // internal: process queue serially
57
+ async _processQueue() {
58
+ if (this._running) return;
59
+ this._running = true;
60
+
61
+ while (this._queue.length) {
62
+ const { store, fn, resolve, reject } = this._queue.shift();
63
+ const prevStore = this._currentStore;
64
+ this._currentStore = store;
65
+
66
+ try {
67
+ const result = fn();
68
+ // await if callback returned a promise
69
+ const awaited = result instanceof Promise ? await result : result;
70
+ resolve(awaited);
71
+ } catch (err) {
72
+ reject(err);
73
+ } finally {
74
+ // restore previous store (if any)
75
+ this._currentStore = prevStore;
76
+ }
77
+ // loop continues to next queued task
78
+ }
79
+
80
+ this._running = false;
81
+ }
82
+ }
83
+ export default AsyncLocalStorage
84
+ `;
85
+ const ALS_SHIM_LOADER = `
86
+ function alsShim(): PluginOption {
87
+ return {
88
+ enforce: 'pre',
89
+ name: 'virtual-async-hooks',
90
+ config() {
91
+ return {
92
+ resolve: {
93
+ alias: {
94
+ // catch both forms
95
+ 'node:async_hooks': '\\0virtual:async_hooks',
96
+ async_hooks: '\\0virtual:async_hooks',
97
+ },
98
+ },
99
+ };
100
+ },
101
+ resolveId(id) {
102
+ if (id === '\\0virtual:async_hooks') return id;
103
+ },
104
+ load(id) {
105
+ if (id !== '\\0virtual:async_hooks') return null;
106
+
107
+ return \`${ALS_SHIM}\`;
108
+ },
109
+ };
110
+ }
111
+ `;
112
+ export default function shimALS(fileName, content) {
113
+ let adjustedContent = content;
114
+ if (fileName === 'vite.config.ts') {
115
+ adjustedContent += ALS_SHIM_LOADER;
116
+ adjustedContent = adjustedContent.replace('plugins: [', `${ALS_RESOLVER}plugins: [alsShim(),`);
117
+ }
118
+ return adjustedContent;
119
+ }
package/package.json CHANGED
@@ -24,6 +24,7 @@
24
24
  "@tanstack/react-query": "^5.66.5",
25
25
  "@uiw/codemirror-theme-github": "^4.23.10",
26
26
  "@uiw/react-codemirror": "^4.23.10",
27
+ "@webcontainer/api": "^1.3.5",
27
28
  "chalk": "^5.4.1",
28
29
  "class-variance-authority": "^0.7.1",
29
30
  "clsx": "^2.1.1",
@@ -36,7 +37,7 @@
36
37
  "sonner": "^2.0.3",
37
38
  "tailwind-merge": "^3.0.2",
38
39
  "zustand": "^5.0.3",
39
- "@tanstack/cta-engine": "0.29.0"
40
+ "@tanstack/cta-engine": "0.29.1"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@types/react": "^19.0.8",
@@ -45,6 +46,6 @@
45
46
  "vite-tsconfig-paths": "^5.1.4",
46
47
  "vitest": "^3.1.4"
47
48
  },
48
- "version": "0.29.0",
49
+ "version": "0.30.0",
49
50
  "scripts": {}
50
51
  }
@@ -14,9 +14,12 @@ import { getFileClass, twClasses } from '../file-classes'
14
14
 
15
15
  import FileViewer from './file-viewer'
16
16
  import FileTree from './file-tree'
17
+ import WebContainerProvider from './web-container-provider'
18
+ import { WebContainerPreview } from './webcontainer-preview'
17
19
 
18
20
  import { Label } from './ui/label'
19
21
  import { Switch } from './ui/switch'
22
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs'
20
23
 
21
24
  import type { FileTreeItem } from '../types'
22
25
 
@@ -181,27 +184,69 @@ export default function FileNavigator() {
181
184
 
182
185
  const ready = useReady()
183
186
 
187
+ // Prepare project files for WebContainer
188
+ const webContainerFiles = useMemo(() => {
189
+ console.log('Preparing WebContainer files, tree:', tree)
190
+ if (!tree) {
191
+ console.log('Tree is empty, returning empty array')
192
+ return []
193
+ }
194
+ const files = Object.entries(tree).map(([path, content]) => ({
195
+ path,
196
+ content,
197
+ }))
198
+ console.log('WebContainer files prepared:', files.length, 'files')
199
+ return files
200
+ }, [tree])
201
+
184
202
  if (!ready) {
185
203
  return null
186
204
  }
187
205
 
188
206
  return (
189
- <div className="bg-white dark:bg-black/50 rounded-lg p-2 sm:p-4">
190
- {mode === 'add' && <Filters />}
191
- <div className="flex flex-row @container">
192
- <div className="w-1/3 @6xl:w-1/4 bg-gray-500/10 rounded-l-lg">
193
- <FileTree selectedFile={selectedFile} tree={fileTree} />
194
- </div>
195
- <div className="w-2/3 @6xl:w-3/4">
196
- {selectedFile && modifiedFileContents ? (
197
- <FileViewer
198
- filePath={selectedFile}
199
- originalFile={originalFileContents}
200
- modifiedFile={modifiedFileContents}
201
- />
202
- ) : null}
203
- </div>
207
+ <WebContainerProvider projectFiles={webContainerFiles}>
208
+ <div className="bg-white dark:bg-black/50 rounded-lg p-2 sm:p-4">
209
+ {mode === 'add' && <Filters />}
210
+ <Tabs defaultValue="files" className="w-full">
211
+ <TabsList className="mb-1 h-7 p-0.5 bg-transparent border border-gray-300 dark:border-gray-700">
212
+ <TabsTrigger
213
+ value="files"
214
+ className="text-xs h-6 px-3 py-0 data-[state=active]:bg-gray-200 dark:data-[state=active]:bg-gray-800"
215
+ >
216
+ Files
217
+ </TabsTrigger>
218
+ <TabsTrigger
219
+ value="preview"
220
+ className="text-xs h-6 px-3 py-0 data-[state=active]:bg-gray-200 dark:data-[state=active]:bg-gray-800"
221
+ >
222
+ Preview
223
+ </TabsTrigger>
224
+ </TabsList>
225
+
226
+ <TabsContent value="files" className="mt-0">
227
+ <div className="flex flex-row @container">
228
+ <div className="w-1/3 @6xl:w-1/4 bg-gray-500/10 rounded-l-lg">
229
+ <FileTree selectedFile={selectedFile} tree={fileTree} />
230
+ </div>
231
+ <div className="w-2/3 @6xl:w-3/4">
232
+ {selectedFile && modifiedFileContents ? (
233
+ <FileViewer
234
+ filePath={selectedFile}
235
+ originalFile={originalFileContents}
236
+ modifiedFile={modifiedFileContents}
237
+ />
238
+ ) : null}
239
+ </div>
240
+ </div>
241
+ </TabsContent>
242
+
243
+ <TabsContent value="preview" className="mt-0">
244
+ <div className="h-[800px]">
245
+ <WebContainerPreview />
246
+ </div>
247
+ </TabsContent>
248
+ </Tabs>
204
249
  </div>
205
- </div>
250
+ </WebContainerProvider>
206
251
  )
207
252
  }
@@ -45,10 +45,18 @@ export default function FileViewer({
45
45
  }
46
46
  const language = getLanguage(filePath)
47
47
 
48
- if (!originalFile || originalFile === modifiedFile) {
48
+ // Display placeholder for binary files
49
+ const displayModified = modifiedFile.startsWith('base64::')
50
+ ? '<binary file>'
51
+ : modifiedFile
52
+ const displayOriginal = originalFile?.startsWith('base64::')
53
+ ? '<binary file>'
54
+ : originalFile
55
+
56
+ if (!displayOriginal || displayOriginal === displayModified) {
49
57
  return (
50
58
  <CodeMirror
51
- value={modifiedFile}
59
+ value={displayModified}
52
60
  theme={theme}
53
61
  height="100vh"
54
62
  width="100%"
@@ -60,8 +68,14 @@ export default function FileViewer({
60
68
  }
61
69
  return (
62
70
  <CodeMirrorMerge orientation="a-b" theme={theme} className="text-lg">
63
- <CodeMirrorMerge.Original value={originalFile} extensions={[language]} />
64
- <CodeMirrorMerge.Modified value={modifiedFile} extensions={[language]} />
71
+ <CodeMirrorMerge.Original
72
+ value={displayOriginal}
73
+ extensions={[language]}
74
+ />
75
+ <CodeMirrorMerge.Modified
76
+ value={displayModified}
77
+ extensions={[language]}
78
+ />
65
79
  </CodeMirrorMerge>
66
80
  )
67
81
  }
@@ -0,0 +1,42 @@
1
+ import { createContext, useEffect, useState } from 'react'
2
+ import { useStore } from 'zustand'
3
+ import createWebContainerStore from '../hooks/use-webcontainer-store'
4
+
5
+ export const WebContainerContext = createContext<ReturnType<
6
+ typeof createWebContainerStore
7
+ > | null>(null)
8
+
9
+ export default function WebContainerProvider({
10
+ children,
11
+ projectFiles,
12
+ }: {
13
+ children: React.ReactNode
14
+ projectFiles: Array<{ path: string; content: string }>
15
+ }) {
16
+ console.log(
17
+ 'WebContainerProvider rendering with',
18
+ projectFiles.length,
19
+ 'files',
20
+ )
21
+ const [containerStore] = useState(() => createWebContainerStore(true))
22
+
23
+ const updateProjectFiles = useStore(
24
+ containerStore,
25
+ (state) => state.updateProjectFiles,
26
+ )
27
+
28
+ useEffect(() => {
29
+ console.log(
30
+ 'WebContainerProvider useEffect triggered with',
31
+ projectFiles.length,
32
+ 'files',
33
+ )
34
+ updateProjectFiles(projectFiles)
35
+ }, [updateProjectFiles, projectFiles])
36
+
37
+ return (
38
+ <WebContainerContext.Provider value={containerStore}>
39
+ {children}
40
+ </WebContainerContext.Provider>
41
+ )
42
+ }
@@ -0,0 +1,241 @@
1
+ import { useContext, useEffect, useRef, useState } from 'react'
2
+ import { useStore } from 'zustand'
3
+ import { ChevronDown, ChevronUp } from 'lucide-react'
4
+
5
+ import type { SetupStep } from '../hooks/use-webcontainer-store'
6
+ import { WebContainerContext } from './web-container-provider'
7
+
8
+ export function WebContainerPreview() {
9
+ const containerStore = useContext(WebContainerContext)
10
+ if (!containerStore) {
11
+ throw new Error('WebContainerContext not found')
12
+ }
13
+
14
+ const webContainer = useStore(containerStore, (state) => state.webContainer)
15
+ const setupStep = useStore(containerStore, (state) => state.setupStep)
16
+ const statusMessage = useStore(containerStore, (state) => state.statusMessage)
17
+ const terminalOutput = useStore(
18
+ containerStore,
19
+ (state) => state.terminalOutput,
20
+ )
21
+ const error = useStore(containerStore, (state) => state.error)
22
+ const previewUrl = useStore(containerStore, (state) => state.previewUrl)
23
+ const startDevServer = useStore(
24
+ containerStore,
25
+ (state) => state.startDevServer,
26
+ )
27
+ const setTerminalOutput = useStore(
28
+ containerStore,
29
+ (state) => state.setTerminalOutput,
30
+ )
31
+
32
+ const [isTerminalOpen, setIsTerminalOpen] = useState(false)
33
+
34
+ // Auto-scroll terminal to bottom when new output arrives
35
+ const terminalRef = useRef<HTMLDivElement>(null)
36
+ useEffect(() => {
37
+ if (terminalRef.current) {
38
+ terminalRef.current.scrollTop = terminalRef.current.scrollHeight
39
+ }
40
+ }, [terminalOutput])
41
+
42
+ const getStepIcon = (step: SetupStep) => {
43
+ switch (step) {
44
+ case 'mounting':
45
+ return '📁'
46
+ case 'installing':
47
+ return '📦'
48
+ case 'starting':
49
+ return '🚀'
50
+ case 'ready':
51
+ return '✅'
52
+ case 'error':
53
+ return '❌'
54
+ }
55
+ }
56
+
57
+ const getStepColor = (step: SetupStep) => {
58
+ switch (step) {
59
+ case 'error':
60
+ return 'text-red-500'
61
+ case 'ready':
62
+ return 'text-green-500'
63
+ default:
64
+ return 'text-blue-500'
65
+ }
66
+ }
67
+
68
+ // Show progress dialog during setup (similar to "Creating Your Application")
69
+ if (
70
+ !webContainer ||
71
+ setupStep === 'error' ||
72
+ setupStep !== 'ready' ||
73
+ !previewUrl
74
+ ) {
75
+ return (
76
+ <div className="flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900">
77
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 max-w-2xl w-full mx-4 border-2 border-blue-500">
78
+ <h2 className="text-2xl font-bold mb-6 text-center">
79
+ {setupStep === 'error' ? 'Setup Failed' : 'Preparing Preview'}
80
+ </h2>
81
+
82
+ {setupStep === 'error' ? (
83
+ <div className="text-center">
84
+ <div className="text-4xl mb-4">❌</div>
85
+ <div className="text-lg font-medium text-red-600 mb-2">
86
+ An error occurred
87
+ </div>
88
+ <div className="text-sm text-gray-600 dark:text-gray-400 mb-4">
89
+ {error}
90
+ </div>
91
+ <button
92
+ onClick={startDevServer}
93
+ className="px-4 py-2 text-sm bg-orange-500 text-white rounded hover:bg-orange-600 transition-colors"
94
+ >
95
+ 🔄 Retry
96
+ </button>
97
+ </div>
98
+ ) : (
99
+ <>
100
+ {/* Progress Steps */}
101
+ <div className="space-y-4 mb-6">
102
+ <div className="flex items-center gap-3">
103
+ <div className="text-2xl">{getStepIcon('mounting')}</div>
104
+ <div
105
+ className={`flex-1 ${getStepColor(setupStep === 'mounting' ? 'mounting' : setupStep === 'installing' || setupStep === 'starting' || setupStep === 'ready' ? 'ready' : 'mounting')}`}
106
+ >
107
+ Mount Files
108
+ </div>
109
+ {(setupStep === 'installing' ||
110
+ setupStep === 'starting' ||
111
+ setupStep === 'ready') &&
112
+ '✓'}
113
+ </div>
114
+ <div className="flex items-center gap-3">
115
+ <div className="text-2xl">{getStepIcon('installing')}</div>
116
+ <div
117
+ className={`flex-1 ${getStepColor(setupStep === 'installing' ? 'installing' : setupStep === 'starting' || setupStep === 'ready' ? 'ready' : 'mounting')}`}
118
+ >
119
+ Install Dependencies
120
+ </div>
121
+ {(setupStep === 'starting' || setupStep === 'ready') && '✓'}
122
+ </div>
123
+ <div className="flex items-center gap-3">
124
+ <div className="text-2xl">{getStepIcon('starting')}</div>
125
+ <div
126
+ className={`flex-1 ${getStepColor(setupStep === 'starting' ? 'starting' : setupStep === 'ready' ? 'ready' : 'mounting')}`}
127
+ >
128
+ Start Server
129
+ </div>
130
+ {setupStep === 'ready' && '✓'}
131
+ </div>
132
+ </div>
133
+
134
+ {/* Current status */}
135
+ <div className="text-center text-sm text-gray-600 dark:text-gray-400">
136
+ {statusMessage || 'Preparing your application...'}
137
+ </div>
138
+ </>
139
+ )}
140
+ </div>
141
+ </div>
142
+ )
143
+ }
144
+
145
+ // Show the running application with collapsible terminal
146
+ return (
147
+ <div className="flex flex-col h-full">
148
+ {/* iframe with the running app */}
149
+ <div className="flex-1">
150
+ {previewUrl ? (
151
+ <iframe
152
+ src={previewUrl}
153
+ className="w-full h-full border-0"
154
+ title="Application Preview"
155
+ onLoad={() => console.log('Iframe loaded successfully')}
156
+ onError={(e) => console.error('Iframe load error:', e)}
157
+ />
158
+ ) : (
159
+ <div className="flex items-center justify-center h-full bg-gray-100 dark:bg-gray-800">
160
+ <div className="text-center">
161
+ <div className="text-2xl mb-2">🔄</div>
162
+ <div className="text-sm text-gray-600 dark:text-gray-400">
163
+ Preview not available
164
+ </div>
165
+ </div>
166
+ </div>
167
+ )}
168
+ </div>
169
+
170
+ {/* Collapsible Terminal output */}
171
+ <div className="border-t border-gray-200 dark:border-gray-700 bg-black text-green-400 flex flex-col flex-shrink-0">
172
+ <div
173
+ className="p-2 border-b border-gray-700 bg-gray-900 flex items-center justify-between cursor-pointer hover:bg-gray-800 transition-colors"
174
+ onClick={() => setIsTerminalOpen(!isTerminalOpen)}
175
+ >
176
+ <div className="flex items-center gap-2 flex-1">
177
+ <button className="text-gray-400 hover:text-gray-200">
178
+ {isTerminalOpen ? (
179
+ <ChevronDown className="w-4 h-4" />
180
+ ) : (
181
+ <ChevronUp className="w-4 h-4" />
182
+ )}
183
+ </button>
184
+ <div className="text-xs font-medium text-gray-300">
185
+ Terminal Output
186
+ </div>
187
+ {setupStep === 'ready' && previewUrl && (
188
+ <div className="text-xs text-green-500">● Server Running</div>
189
+ )}
190
+ </div>
191
+ <div
192
+ className="flex items-center gap-2"
193
+ onClick={(e) => e.stopPropagation()}
194
+ >
195
+ {previewUrl && (
196
+ <button
197
+ onClick={() => window.open(previewUrl, '_blank')}
198
+ className="px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
199
+ >
200
+ Open in New Tab
201
+ </button>
202
+ )}
203
+ <button
204
+ onClick={startDevServer}
205
+ disabled={!webContainer}
206
+ className="px-2 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 transition-colors disabled:opacity-50"
207
+ >
208
+ 🔄 Restart
209
+ </button>
210
+ <button
211
+ onClick={() => setTerminalOutput([])}
212
+ className="text-xs text-gray-400 hover:text-gray-200 transition-colors"
213
+ >
214
+ Clear
215
+ </button>
216
+ </div>
217
+ </div>
218
+ {isTerminalOpen && (
219
+ <div
220
+ ref={terminalRef}
221
+ className="font-mono text-xs p-2 overflow-y-auto overflow-x-hidden"
222
+ style={{ maxHeight: '200px' }}
223
+ >
224
+ {terminalOutput.length > 0 ? (
225
+ terminalOutput.map((line, index) => (
226
+ <div
227
+ key={index}
228
+ className="mb-1 leading-tight whitespace-pre-wrap break-words"
229
+ >
230
+ {line}
231
+ </div>
232
+ ))
233
+ ) : (
234
+ <div className="text-gray-500">No output yet...</div>
235
+ )}
236
+ </div>
237
+ )}
238
+ </div>
239
+ </div>
240
+ )
241
+ }
@@ -0,0 +1,7 @@
1
+ import { useContext } from 'react'
2
+ import { WebContainerContext } from '../components/web-container-provider'
3
+
4
+ export function useWebContainer() {
5
+ const webContainer = useContext(WebContainerContext)
6
+ return webContainer
7
+ }