@tutorialkit-rb/runtime 1.5.2-rb.0.1.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/README.md +18 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/lesson-files.d.ts +22 -0
- package/dist/lesson-files.js +126 -0
- package/dist/store/editor.d.ts +29 -0
- package/dist/store/editor.js +127 -0
- package/dist/store/index.d.ts +113 -0
- package/dist/store/index.js +316 -0
- package/dist/store/previews.d.ts +19 -0
- package/dist/store/previews.js +80 -0
- package/dist/store/terminal.d.ts +24 -0
- package/dist/store/terminal.js +128 -0
- package/dist/store/tutorial-runner.d.ts +147 -0
- package/dist/store/tutorial-runner.js +564 -0
- package/dist/tasks.d.ts +22 -0
- package/dist/tasks.js +26 -0
- package/dist/utils/multi-counter.d.ts +5 -0
- package/dist/utils/multi-counter.js +19 -0
- package/dist/utils/promises.d.ts +8 -0
- package/dist/utils/promises.js +29 -0
- package/dist/utils/support.d.ts +1 -0
- package/dist/utils/support.js +23 -0
- package/dist/utils/terminal.d.ts +17 -0
- package/dist/utils/terminal.js +13 -0
- package/dist/webcontainer/command.d.ts +28 -0
- package/dist/webcontainer/command.js +67 -0
- package/dist/webcontainer/editor-config.d.ts +12 -0
- package/dist/webcontainer/editor-config.js +60 -0
- package/dist/webcontainer/index.d.ts +4 -0
- package/dist/webcontainer/index.js +4 -0
- package/dist/webcontainer/on-demand-boot.d.ts +15 -0
- package/dist/webcontainer/on-demand-boot.js +39 -0
- package/dist/webcontainer/port-info.d.ts +6 -0
- package/dist/webcontainer/port-info.js +10 -0
- package/dist/webcontainer/preview-info.d.ts +21 -0
- package/dist/webcontainer/preview-info.js +56 -0
- package/dist/webcontainer/shell.d.ts +14 -0
- package/dist/webcontainer/shell.js +46 -0
- package/dist/webcontainer/steps.d.ts +15 -0
- package/dist/webcontainer/steps.js +38 -0
- package/dist/webcontainer/terminal-config.d.ts +59 -0
- package/dist/webcontainer/terminal-config.js +230 -0
- package/dist/webcontainer/utils/files.d.ts +10 -0
- package/dist/webcontainer/utils/files.js +76 -0
- package/package.json +53 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { atom } from 'nanostores';
|
|
2
|
+
import { LessonFilesFetcher } from '../lesson-files.js';
|
|
3
|
+
import { newTask } from '../tasks.js';
|
|
4
|
+
import { bootStatus, unblockBoot } from '../webcontainer/on-demand-boot.js';
|
|
5
|
+
import { StepsController } from '../webcontainer/steps.js';
|
|
6
|
+
import { EditorStore } from './editor.js';
|
|
7
|
+
import { PreviewsStore } from './previews.js';
|
|
8
|
+
import { TerminalStore } from './terminal.js';
|
|
9
|
+
import { TutorialRunner } from './tutorial-runner.js';
|
|
10
|
+
export class TutorialStore {
|
|
11
|
+
_webcontainer;
|
|
12
|
+
_runner;
|
|
13
|
+
_previewsStore;
|
|
14
|
+
_editorStore;
|
|
15
|
+
_terminalStore;
|
|
16
|
+
_stepController = new StepsController();
|
|
17
|
+
_lessonFilesFetcher;
|
|
18
|
+
_lessonTask;
|
|
19
|
+
_lesson;
|
|
20
|
+
_ref = atom(1);
|
|
21
|
+
_themeRef = atom(1);
|
|
22
|
+
_lessonFiles;
|
|
23
|
+
_lessonSolution;
|
|
24
|
+
_lessonTemplate;
|
|
25
|
+
/**
|
|
26
|
+
* Whether or not the current lesson is fully loaded in WebContainer
|
|
27
|
+
* and in every stores.
|
|
28
|
+
*/
|
|
29
|
+
lessonFullyLoaded = atom(false);
|
|
30
|
+
constructor({ useAuth, webcontainer, basePathname }) {
|
|
31
|
+
this._webcontainer = webcontainer;
|
|
32
|
+
this._editorStore = new EditorStore();
|
|
33
|
+
this._lessonFilesFetcher = new LessonFilesFetcher(basePathname);
|
|
34
|
+
this._previewsStore = new PreviewsStore(this._webcontainer);
|
|
35
|
+
this._terminalStore = new TerminalStore(this._webcontainer, useAuth);
|
|
36
|
+
this._runner = new TutorialRunner(this._webcontainer, this._terminalStore, this._editorStore, this._stepController);
|
|
37
|
+
/**
|
|
38
|
+
* By having this code under `import.meta.hot`, it gets:
|
|
39
|
+
* - ignored on server side where it shouldn't run
|
|
40
|
+
* - discarded when doing a production build
|
|
41
|
+
*/
|
|
42
|
+
if (import.meta.hot) {
|
|
43
|
+
import.meta.hot.on('tk:refresh-wc-files', async (hotFilesRefs) => {
|
|
44
|
+
let shouldUpdate = false;
|
|
45
|
+
for (const filesRef of hotFilesRefs) {
|
|
46
|
+
const result = await this._lessonFilesFetcher.invalidate(filesRef);
|
|
47
|
+
switch (result.type) {
|
|
48
|
+
case 'none': {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case 'files': {
|
|
52
|
+
if (this._lesson?.files[0] === filesRef) {
|
|
53
|
+
shouldUpdate = true;
|
|
54
|
+
this._lesson.files[1] = Object.keys(result.files).sort();
|
|
55
|
+
this._lessonFiles = result.files;
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case 'solution': {
|
|
60
|
+
if (this._lesson?.solution[0] === filesRef) {
|
|
61
|
+
shouldUpdate = true;
|
|
62
|
+
this._lesson.solution[1] = Object.keys(result.files).sort();
|
|
63
|
+
this._lessonSolution = result.files;
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case 'template': {
|
|
68
|
+
shouldUpdate = true;
|
|
69
|
+
this._lessonTemplate = result.files;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (shouldUpdate && this._lesson) {
|
|
75
|
+
this._lessonTask?.cancel();
|
|
76
|
+
const files = this._lessonFiles ?? {};
|
|
77
|
+
const template = this._lessonTemplate;
|
|
78
|
+
this._lessonTask = newTask(async (signal) => {
|
|
79
|
+
const preparePromise = this._runner.prepareFiles({ template, files, signal });
|
|
80
|
+
this._runner.runCommands();
|
|
81
|
+
this._editorStore.setDocuments(files);
|
|
82
|
+
await preparePromise;
|
|
83
|
+
}, { ignoreCancel: true });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** @internal */
|
|
89
|
+
setLesson(lesson, options = {}) {
|
|
90
|
+
if (lesson === this._lesson) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this._lessonTask?.cancel();
|
|
94
|
+
this._ref.set(1 + (this._ref.value || 0));
|
|
95
|
+
this._lesson = lesson;
|
|
96
|
+
this.lessonFullyLoaded.set(false);
|
|
97
|
+
this._previewsStore.setPreviews(lesson.data.previews ?? true);
|
|
98
|
+
this._terminalStore.setTerminalConfiguration(lesson.data.terminal);
|
|
99
|
+
this._editorStore.setEditorConfig(lesson.data.editor);
|
|
100
|
+
this._runner.setCommands(lesson.data);
|
|
101
|
+
this._editorStore.setDocuments(lesson.files);
|
|
102
|
+
if (options.ssr) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this._runner.setWatchFromWebContainer(lesson.data.filesystem?.watch ?? false);
|
|
106
|
+
this._lessonTask = newTask(async (signal) => {
|
|
107
|
+
const templatePromise = this._lessonFilesFetcher.getLessonTemplate(lesson);
|
|
108
|
+
const filesPromise = this._lessonFilesFetcher.getLessonFiles(lesson);
|
|
109
|
+
const removePaths = lesson?.data?.custom?.fs?.remove;
|
|
110
|
+
const preparePromise = this._runner.prepareFiles({
|
|
111
|
+
template: templatePromise,
|
|
112
|
+
files: filesPromise,
|
|
113
|
+
signal,
|
|
114
|
+
removePaths,
|
|
115
|
+
});
|
|
116
|
+
this._runner.runCommands();
|
|
117
|
+
const [template, solution, files] = await Promise.all([
|
|
118
|
+
templatePromise,
|
|
119
|
+
this._lessonFilesFetcher.getLessonSolution(lesson),
|
|
120
|
+
filesPromise,
|
|
121
|
+
]);
|
|
122
|
+
signal.throwIfAborted();
|
|
123
|
+
this._lessonTemplate = template;
|
|
124
|
+
this._lessonFiles = files;
|
|
125
|
+
this._lessonSolution = solution;
|
|
126
|
+
this._editorStore.setDocuments(files);
|
|
127
|
+
if (lesson.data.focus === undefined) {
|
|
128
|
+
this._editorStore.setSelectedFile(undefined);
|
|
129
|
+
}
|
|
130
|
+
else if (files[lesson.data.focus] !== undefined) {
|
|
131
|
+
this._editorStore.setSelectedFile(lesson.data.focus);
|
|
132
|
+
}
|
|
133
|
+
await preparePromise;
|
|
134
|
+
signal.throwIfAborted();
|
|
135
|
+
this.lessonFullyLoaded.set(true);
|
|
136
|
+
}, { ignoreCancel: true });
|
|
137
|
+
}
|
|
138
|
+
/** Instances of the preview tabs. */
|
|
139
|
+
get previews() {
|
|
140
|
+
return this._previewsStore.previews;
|
|
141
|
+
}
|
|
142
|
+
/** Configuration and instances of the terminal */
|
|
143
|
+
get terminalConfig() {
|
|
144
|
+
return this._terminalStore.terminalConfig;
|
|
145
|
+
}
|
|
146
|
+
/** Configuration of the editor and file tree */
|
|
147
|
+
get editorConfig() {
|
|
148
|
+
return this._editorStore.editorConfig;
|
|
149
|
+
}
|
|
150
|
+
/** File that's currently open in the editor */
|
|
151
|
+
get currentDocument() {
|
|
152
|
+
return this._editorStore.currentDocument;
|
|
153
|
+
}
|
|
154
|
+
/** Status of the webcontainer's booting */
|
|
155
|
+
get bootStatus() {
|
|
156
|
+
return bootStatus;
|
|
157
|
+
}
|
|
158
|
+
/** Files that are available in the editor. */
|
|
159
|
+
get documents() {
|
|
160
|
+
return this._editorStore.documents;
|
|
161
|
+
}
|
|
162
|
+
/** Paths of the files that are available in the lesson */
|
|
163
|
+
get files() {
|
|
164
|
+
return this._editorStore.files;
|
|
165
|
+
}
|
|
166
|
+
/** File that's currently selected in the file tree */
|
|
167
|
+
get selectedFile() {
|
|
168
|
+
return this._editorStore.selectedFile;
|
|
169
|
+
}
|
|
170
|
+
/** Currently active lesson */
|
|
171
|
+
get lesson() {
|
|
172
|
+
return this._lesson;
|
|
173
|
+
}
|
|
174
|
+
/** @internal */
|
|
175
|
+
get ref() {
|
|
176
|
+
return this._ref;
|
|
177
|
+
}
|
|
178
|
+
/** @internal */
|
|
179
|
+
get themeRef() {
|
|
180
|
+
return this._themeRef;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Steps that the runner is or will be executing.
|
|
184
|
+
*
|
|
185
|
+
* @internal
|
|
186
|
+
*/
|
|
187
|
+
get steps() {
|
|
188
|
+
return this._stepController.steps;
|
|
189
|
+
}
|
|
190
|
+
/** Check if file tree is visible */
|
|
191
|
+
hasFileTree() {
|
|
192
|
+
if (!this._lesson) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
return this.editorConfig.get().fileTree.visible;
|
|
196
|
+
}
|
|
197
|
+
/** Check if editor is visible */
|
|
198
|
+
hasEditor() {
|
|
199
|
+
if (!this._lesson) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
return this.editorConfig.get().visible;
|
|
203
|
+
}
|
|
204
|
+
/** Check if lesson has any previews set */
|
|
205
|
+
hasPreviews() {
|
|
206
|
+
if (!this._lesson) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
const { previews } = this._lesson.data;
|
|
210
|
+
return previews !== false;
|
|
211
|
+
}
|
|
212
|
+
/** Check if lesson has any terminals set */
|
|
213
|
+
hasTerminalPanel() {
|
|
214
|
+
return this._terminalStore.hasTerminalPanel();
|
|
215
|
+
}
|
|
216
|
+
/** Check if lesson has solution files set */
|
|
217
|
+
hasSolution() {
|
|
218
|
+
return !!this._lesson && Object.keys(this._lesson.solution[1]).length >= 1;
|
|
219
|
+
}
|
|
220
|
+
/** Unlock webcontainer's boot process if it was in `'blocked'` state */
|
|
221
|
+
unblockBoot() {
|
|
222
|
+
unblockBoot();
|
|
223
|
+
}
|
|
224
|
+
/** Reset changed files back to lesson's initial state */
|
|
225
|
+
reset() {
|
|
226
|
+
const isReady = this.lessonFullyLoaded.value;
|
|
227
|
+
if (!isReady || !this._lessonFiles) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
this._editorStore.setDocuments(this._lessonFiles);
|
|
231
|
+
this._runner.updateFiles(this._lessonFiles);
|
|
232
|
+
}
|
|
233
|
+
/** Apply lesson solution into the lesson files */
|
|
234
|
+
solve() {
|
|
235
|
+
const isReady = this.lessonFullyLoaded.value;
|
|
236
|
+
if (!isReady || !this._lessonSolution) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const files = { ...this._lessonFiles, ...this._lessonSolution };
|
|
240
|
+
this._editorStore.setDocuments(files);
|
|
241
|
+
this._runner.updateFiles(files);
|
|
242
|
+
}
|
|
243
|
+
/** Set file from file tree as selected */
|
|
244
|
+
setSelectedFile(filePath) {
|
|
245
|
+
this._editorStore.setSelectedFile(filePath);
|
|
246
|
+
}
|
|
247
|
+
/** Add new file to file tree */
|
|
248
|
+
async addFile(filePath) {
|
|
249
|
+
// always select the existing or newly created file
|
|
250
|
+
this.setSelectedFile(filePath);
|
|
251
|
+
// prevent creating duplicates
|
|
252
|
+
if (this._editorStore.files.get().find((file) => file.path === filePath)) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (await this._runner.fileExists(filePath)) {
|
|
256
|
+
throw new Error('FILE_EXISTS');
|
|
257
|
+
}
|
|
258
|
+
this._editorStore.addFileOrFolder({ path: filePath, type: 'file' });
|
|
259
|
+
this._runner.updateFile(filePath, '');
|
|
260
|
+
}
|
|
261
|
+
/** Add new folder to file tree */
|
|
262
|
+
async addFolder(folderPath) {
|
|
263
|
+
// prevent creating duplicates
|
|
264
|
+
if (this._editorStore.files.get().some((file) => file.path.startsWith(folderPath))) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (await this._runner.folderExists(folderPath)) {
|
|
268
|
+
throw new Error('FOLDER_EXISTS');
|
|
269
|
+
}
|
|
270
|
+
this._editorStore.addFileOrFolder({ path: folderPath, type: 'folder' });
|
|
271
|
+
this._runner.createFolder(folderPath);
|
|
272
|
+
}
|
|
273
|
+
/** Update contents of file */
|
|
274
|
+
updateFile(filePath, content) {
|
|
275
|
+
const hasChanged = this._editorStore.updateFile(filePath, content);
|
|
276
|
+
if (hasChanged) {
|
|
277
|
+
this._runner.updateFile(filePath, content);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** Update content of the active file */
|
|
281
|
+
setCurrentDocumentContent(newContent) {
|
|
282
|
+
const filePath = this.currentDocument.get()?.filePath;
|
|
283
|
+
if (!filePath) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.updateFile(filePath, newContent);
|
|
287
|
+
}
|
|
288
|
+
/** Update scroll position of the file in editor */
|
|
289
|
+
setCurrentDocumentScrollPosition(position) {
|
|
290
|
+
const editorDocument = this.currentDocument.get();
|
|
291
|
+
if (!editorDocument) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const { filePath } = editorDocument;
|
|
295
|
+
this._editorStore.updateScrollPosition(filePath, position);
|
|
296
|
+
}
|
|
297
|
+
/** @internal */
|
|
298
|
+
attachTerminal(id, terminal) {
|
|
299
|
+
this._terminalStore.attachTerminal(id, terminal);
|
|
300
|
+
}
|
|
301
|
+
/** Callback that should be called when terminal resizes */
|
|
302
|
+
onTerminalResize(cols, rows) {
|
|
303
|
+
if (cols && rows) {
|
|
304
|
+
this._terminalStore.onTerminalResize(cols, rows);
|
|
305
|
+
this._runner.onTerminalResize(cols, rows);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/** Listen for file changes made in the editor */
|
|
309
|
+
onDocumentChanged(filePath, callback) {
|
|
310
|
+
return this._editorStore.onDocumentChanged(filePath, callback);
|
|
311
|
+
}
|
|
312
|
+
/** Take snapshot of the current state of the lesson */
|
|
313
|
+
takeSnapshot() {
|
|
314
|
+
return this._runner.takeSnapshot();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PreviewSchema } from '@tutorialkit-rb/types';
|
|
2
|
+
import type { WebContainer } from '@webcontainer/api';
|
|
3
|
+
import { PreviewInfo } from '../webcontainer/preview-info.js';
|
|
4
|
+
export declare class PreviewsStore {
|
|
5
|
+
private _availablePreviews;
|
|
6
|
+
private _previewsLayout;
|
|
7
|
+
/**
|
|
8
|
+
* Atom representing the current previews. If it's an empty array or none of
|
|
9
|
+
* the previews are ready, then no preview can be shown.
|
|
10
|
+
*/
|
|
11
|
+
previews: import("nanostores").WritableAtom<PreviewInfo[]>;
|
|
12
|
+
constructor(webcontainerPromise: Promise<WebContainer>);
|
|
13
|
+
private _init;
|
|
14
|
+
/**
|
|
15
|
+
* Set the expected port for the preview to show. If this is not set,
|
|
16
|
+
* the port of the first server that is ready will be used.
|
|
17
|
+
*/
|
|
18
|
+
setPreviews(config: PreviewSchema): void;
|
|
19
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { atom } from 'nanostores';
|
|
2
|
+
import { PortInfo } from '../webcontainer/port-info.js';
|
|
3
|
+
import { PreviewInfo } from '../webcontainer/preview-info.js';
|
|
4
|
+
export class PreviewsStore {
|
|
5
|
+
_availablePreviews = new Map();
|
|
6
|
+
_previewsLayout = [];
|
|
7
|
+
/**
|
|
8
|
+
* Atom representing the current previews. If it's an empty array or none of
|
|
9
|
+
* the previews are ready, then no preview can be shown.
|
|
10
|
+
*/
|
|
11
|
+
previews = atom([]);
|
|
12
|
+
constructor(webcontainerPromise) {
|
|
13
|
+
this._init(webcontainerPromise);
|
|
14
|
+
}
|
|
15
|
+
async _init(webcontainerPromise) {
|
|
16
|
+
const webcontainer = await webcontainerPromise;
|
|
17
|
+
webcontainer.on('port', (port, type, url) => {
|
|
18
|
+
let portInfo = this._availablePreviews.get(port);
|
|
19
|
+
if (!portInfo) {
|
|
20
|
+
portInfo = new PortInfo(port, url, type === 'open');
|
|
21
|
+
this._availablePreviews.set(port, portInfo);
|
|
22
|
+
}
|
|
23
|
+
portInfo.ready = type === 'open';
|
|
24
|
+
portInfo.origin = url;
|
|
25
|
+
if (this._previewsLayout.length === 0) {
|
|
26
|
+
this.previews.set([new PreviewInfo({}, portInfo)]);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
this._previewsLayout = [...this._previewsLayout];
|
|
30
|
+
this.previews.set(this._previewsLayout);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Set the expected port for the preview to show. If this is not set,
|
|
36
|
+
* the port of the first server that is ready will be used.
|
|
37
|
+
*/
|
|
38
|
+
setPreviews(config) {
|
|
39
|
+
if (config === false) {
|
|
40
|
+
// clear the previews if they are turned off
|
|
41
|
+
this.previews.set([]);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// if the schema is `true`, we just use the default empty array
|
|
45
|
+
const previews = config === true ? [] : (config ?? []);
|
|
46
|
+
const previewInfos = previews.map((previewConfig) => {
|
|
47
|
+
const preview = PreviewInfo.parse(previewConfig);
|
|
48
|
+
let portInfo = this._availablePreviews.get(preview.port);
|
|
49
|
+
if (!portInfo) {
|
|
50
|
+
portInfo = new PortInfo(preview.port);
|
|
51
|
+
this._availablePreviews.set(preview.port, portInfo);
|
|
52
|
+
}
|
|
53
|
+
return new PreviewInfo(preview, portInfo);
|
|
54
|
+
});
|
|
55
|
+
let areDifferent = previewInfos.length != this._previewsLayout.length;
|
|
56
|
+
if (!areDifferent) {
|
|
57
|
+
for (let i = 0; i < previewInfos.length; i++) {
|
|
58
|
+
areDifferent = !PreviewInfo.equals(previewInfos[i], this._previewsLayout[i]);
|
|
59
|
+
if (areDifferent) {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!areDifferent) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this._previewsLayout = previewInfos;
|
|
68
|
+
/**
|
|
69
|
+
* If a port is provided and the preview is already ready we update the previewUrl.
|
|
70
|
+
* If no port is provided we default to the first preview ever to ready if there are any.
|
|
71
|
+
*/
|
|
72
|
+
if (previews.length === 0) {
|
|
73
|
+
const firstPreview = this._availablePreviews.values().next().value;
|
|
74
|
+
this.previews.set(firstPreview ? [firstPreview] : []);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
this.previews.set(this._previewsLayout);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TerminalSchema } from '@tutorialkit-rb/types';
|
|
2
|
+
import { WebContainer } from '@webcontainer/api';
|
|
3
|
+
import { type ITerminal } from '../utils/terminal.js';
|
|
4
|
+
import { TerminalConfig } from '../webcontainer/terminal-config.js';
|
|
5
|
+
export declare class TerminalStore {
|
|
6
|
+
private _webcontainer;
|
|
7
|
+
private _useAuth;
|
|
8
|
+
terminalConfig: import("nanostores").WritableAtom<TerminalConfig>;
|
|
9
|
+
private _output;
|
|
10
|
+
private _webcontainerLoaded;
|
|
11
|
+
constructor(_webcontainer: Promise<WebContainer>, _useAuth: boolean);
|
|
12
|
+
getOutputPanel(): ITerminal | undefined;
|
|
13
|
+
hasTerminalPanel(): boolean;
|
|
14
|
+
setTerminalConfiguration(config?: TerminalSchema): void;
|
|
15
|
+
/**
|
|
16
|
+
* Attaches the provided terminal with the panel matching the provided ID.
|
|
17
|
+
*
|
|
18
|
+
* @param id The ID of the panel to attach the terminal with.
|
|
19
|
+
* @param terminal The terminal to hook up to the JSH process.
|
|
20
|
+
*/
|
|
21
|
+
attachTerminal(id: string, terminal: ITerminal): Promise<void>;
|
|
22
|
+
onTerminalResize(cols: number, rows: number): void;
|
|
23
|
+
private _bootWebContainer;
|
|
24
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { WebContainer, auth } from '@webcontainer/api';
|
|
2
|
+
import { atom } from 'nanostores';
|
|
3
|
+
import { tick } from '../utils/promises.js';
|
|
4
|
+
import { isWebContainerSupported } from '../utils/support.js';
|
|
5
|
+
import { clearTerminal, escapeCodes } from '../utils/terminal.js';
|
|
6
|
+
import { newJSHProcess } from '../webcontainer/shell.js';
|
|
7
|
+
import { TerminalConfig, TerminalPanel } from '../webcontainer/terminal-config.js';
|
|
8
|
+
export class TerminalStore {
|
|
9
|
+
_webcontainer;
|
|
10
|
+
_useAuth;
|
|
11
|
+
terminalConfig = atom(new TerminalConfig());
|
|
12
|
+
_output = undefined;
|
|
13
|
+
_webcontainerLoaded = false;
|
|
14
|
+
constructor(_webcontainer, _useAuth) {
|
|
15
|
+
this._webcontainer = _webcontainer;
|
|
16
|
+
this._useAuth = _useAuth;
|
|
17
|
+
this._webcontainer.then(() => {
|
|
18
|
+
this._webcontainerLoaded = true;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
getOutputPanel() {
|
|
22
|
+
return this._output;
|
|
23
|
+
}
|
|
24
|
+
hasTerminalPanel() {
|
|
25
|
+
return this.terminalConfig.get().panels.length > 0;
|
|
26
|
+
}
|
|
27
|
+
setTerminalConfiguration(config) {
|
|
28
|
+
const oldTerminalConfig = this.terminalConfig.get();
|
|
29
|
+
const newTerminalConfig = new TerminalConfig(config);
|
|
30
|
+
// iterate over the old terminal config and make a list of all terminal panels
|
|
31
|
+
const panelMap = new Map(oldTerminalConfig.panels.map((panel) => [panel.id, panel]));
|
|
32
|
+
// iterate over the new terminal panels and try to re-use the old terminal with the new panel
|
|
33
|
+
for (const panel of newTerminalConfig.panels) {
|
|
34
|
+
const oldPanel = panelMap.get(panel.id);
|
|
35
|
+
panelMap.delete(panel.id);
|
|
36
|
+
if (oldPanel?.terminal) {
|
|
37
|
+
// if we found a previous panel with the same id, attach that terminal to the new panel
|
|
38
|
+
panel.attachTerminal(oldPanel.terminal);
|
|
39
|
+
}
|
|
40
|
+
if (panel.type === 'output') {
|
|
41
|
+
this._output = panel;
|
|
42
|
+
}
|
|
43
|
+
if (panel.type === 'terminal' && !oldPanel) {
|
|
44
|
+
// if the panel is a terminal panel, and this panel didn't exist before, spawn a new JSH process
|
|
45
|
+
this._bootWebContainer(panel)
|
|
46
|
+
.then(async (webcontainerInstance) => {
|
|
47
|
+
panel.attachProcess(await newJSHProcess(webcontainerInstance, panel, panel.processOptions));
|
|
48
|
+
// this.terminalConfig.notify(this.terminalConfig.get());
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {
|
|
51
|
+
// do nothing
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// kill all old processes which we couldn't re-use
|
|
56
|
+
for (const panel of panelMap.values()) {
|
|
57
|
+
panel.process?.kill();
|
|
58
|
+
}
|
|
59
|
+
this.terminalConfig.set(newTerminalConfig);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Attaches the provided terminal with the panel matching the provided ID.
|
|
63
|
+
*
|
|
64
|
+
* @param id The ID of the panel to attach the terminal with.
|
|
65
|
+
* @param terminal The terminal to hook up to the JSH process.
|
|
66
|
+
*/
|
|
67
|
+
async attachTerminal(id, terminal) {
|
|
68
|
+
const panel = this.terminalConfig.get().panels.find((panel) => panel.id === id);
|
|
69
|
+
if (!panel) {
|
|
70
|
+
// if we don't have a panel with the provided id, just exit
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
panel.attachTerminal(terminal);
|
|
74
|
+
}
|
|
75
|
+
onTerminalResize(cols, rows) {
|
|
76
|
+
// iterate over all terminal panels and resize all processes
|
|
77
|
+
for (const panel of this.terminalConfig.get().panels) {
|
|
78
|
+
panel.process?.resize({ cols, rows });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async _bootWebContainer(terminal) {
|
|
82
|
+
validateWebContainerSupported(terminal);
|
|
83
|
+
const isLoaded = this._webcontainerLoaded;
|
|
84
|
+
if (this._useAuth && !isLoaded) {
|
|
85
|
+
terminal.write('Waiting for authentication to complete...');
|
|
86
|
+
await auth.loggedIn();
|
|
87
|
+
clearTerminal(terminal);
|
|
88
|
+
}
|
|
89
|
+
if (!isLoaded) {
|
|
90
|
+
terminal.write('Booting WebContainer...');
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const webcontainerInstance = await this._webcontainer;
|
|
94
|
+
if (!isLoaded) {
|
|
95
|
+
clearTerminal(terminal);
|
|
96
|
+
}
|
|
97
|
+
return webcontainerInstance;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
clearTerminal(terminal);
|
|
101
|
+
await tick();
|
|
102
|
+
terminal.write([
|
|
103
|
+
escapeCodes.red(`Looks like your browser's configuration is blocking WebContainers.`),
|
|
104
|
+
'',
|
|
105
|
+
`Let's troubleshoot this!`,
|
|
106
|
+
'',
|
|
107
|
+
'Read more at:',
|
|
108
|
+
'https://webcontainers.io/guides/browser-config',
|
|
109
|
+
'',
|
|
110
|
+
].join('\n'));
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function validateWebContainerSupported(terminal) {
|
|
116
|
+
if (!isWebContainerSupported()) {
|
|
117
|
+
terminal.write([
|
|
118
|
+
escapeCodes.red('Incompatible Web Browser'),
|
|
119
|
+
'',
|
|
120
|
+
`WebContainers currently work in Chromium-based browsers, Firefox, and Safari 16.4. We're hoping to add support for more browsers as they implement the necessary Web Platform features.`,
|
|
121
|
+
'',
|
|
122
|
+
'Read more about browser support:',
|
|
123
|
+
'https://webcontainers.io/guides/browser-support',
|
|
124
|
+
'',
|
|
125
|
+
].join('\n'));
|
|
126
|
+
throw new Error('Incompatible Web Browser');
|
|
127
|
+
}
|
|
128
|
+
}
|