@tanstack/cta-ui-base 0.29.1 → 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.
- package/dist/components/file-navigator.js +18 -1
- package/dist/components/file-viewer.js +10 -3
- package/dist/components/web-container-provider.d.ts +31 -0
- package/dist/components/web-container-provider.js +15 -0
- package/dist/components/webcontainer-preview.d.ts +1 -0
- package/dist/components/webcontainer-preview.js +63 -0
- package/dist/hooks/use-web-container.d.ts +24 -0
- package/dist/hooks/use-web-container.js +6 -0
- package/dist/hooks/use-webcontainer-store.d.ts +29 -0
- package/dist/hooks/use-webcontainer-store.js +334 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/lib/als-shim.d.ts +1 -0
- package/dist/lib/als-shim.js +119 -0
- package/package.json +2 -1
- package/src/components/file-navigator.tsx +61 -16
- package/src/components/file-viewer.tsx +18 -4
- package/src/components/web-container-provider.tsx +42 -0
- package/src/components/webcontainer-preview.tsx +241 -0
- package/src/hooks/use-web-container.ts +7 -0
- package/src/hooks/use-webcontainer-store.ts +436 -0
- package/src/index.ts +7 -1
- package/src/lib/als-shim.ts +125 -0
|
@@ -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",
|
|
@@ -45,6 +46,6 @@
|
|
|
45
46
|
"vite-tsconfig-paths": "^5.1.4",
|
|
46
47
|
"vitest": "^3.1.4"
|
|
47
48
|
},
|
|
48
|
-
"version": "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
|
-
<
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
<
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
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={
|
|
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
|
|
64
|
-
|
|
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
|
+
}
|