Skip to content

Commit ff284ba

Browse files
Rich-HarrisRich Harris
and
Rich Harris
authored
add terminal viewer - closes sveltejs#93 (sveltejs#251)
Co-authored-by: Rich Harris <[email protected]>
1 parent 0aea741 commit ff284ba

File tree

8 files changed

+116
-26
lines changed

8 files changed

+116
-26
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@rich_harris/svelte-split-pane": "^1.0.0",
3737
"@webcontainer/api": "^1.0.2",
3838
"adm-zip": "^0.5.10",
39+
"ansi-to-html": "^0.7.2",
3940
"base64-js": "^1.5.1",
4041
"marked": "^4.2.12",
4142
"monaco-editor": "^0.36.1",

pnpm-lock.yaml

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/client/adapters/webcontainer/index.js

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,24 @@
11
import { WebContainer } from '@webcontainer/api';
22
import base64 from 'base64-js';
3+
import AnsiToHtml from 'ansi-to-html';
34
import { get_depth } from '../../../utils.js';
45
import { ready } from '../common/index.js';
56

7+
const converter = new AnsiToHtml({
8+
fg: 'var(--sk-text-3)'
9+
});
10+
611
/** @type {import('@webcontainer/api').WebContainer} Web container singleton */
712
let vm;
813

9-
/** @param {string} label */
10-
function console_stream(label) {
11-
return new WritableStream({
12-
write(chunk) {
13-
console.log(`[${label}] ${chunk}`);
14-
}
15-
});
16-
}
17-
1814
/**
1915
* @param {import('svelte/store').Writable<string | null>} base
2016
* @param {import('svelte/store').Writable<Error | null>} error
2117
* @param {import('svelte/store').Writable<{ value: number, text: string }>} progress
18+
* @param {import('svelte/store').Writable<string[]>} logs
2219
* @returns {Promise<import('$lib/types').Adapter>}
2320
*/
24-
export async function create(base, error, progress) {
21+
export async function create(base, error, progress, logs) {
2522
if (/safari/i.test(navigator.userAgent) && !/chrome/i.test(navigator.userAgent)) {
2623
throw new Error('WebContainers are not supported by Safari');
2724
}
@@ -55,9 +52,22 @@ export async function create(base, error, progress) {
5552
}
5653
});
5754

55+
const log_stream = () =>
56+
new WritableStream({
57+
write(chunk) {
58+
if (chunk === '\x1B[1;1H') {
59+
// clear screen
60+
logs.set([]);
61+
} else {
62+
const log = converter.toHtml(chunk);
63+
logs.update(($logs) => [...$logs, log]);
64+
}
65+
}
66+
});
67+
5868
progress.set({ value: 3 / 5, text: 'unzipping files' });
5969
const unzip = await vm.spawn('node', ['unzip.cjs']);
60-
unzip.output.pipeTo(console_stream('unzip'));
70+
unzip.output.pipeTo(log_stream());
6171
const code = await unzip.exit;
6272

6373
if (code !== 0) {
@@ -101,7 +111,7 @@ export async function create(base, error, progress) {
101111

102112
// TODO differentiate between stdout and stderr (sets `vite_error` to `true`)
103113
// https://github.com/stackblitz/webcontainer-core/issues/971
104-
process.output.pipeTo(console_stream('dev'));
114+
process.output.pipeTo(log_stream());
105115

106116
// keep restarting dev server (can crash in case of illegal +files for example)
107117
await process.exit;

src/lib/icons/terminal.svg

Lines changed: 7 additions & 0 deletions
Loading

src/routes/tutorial/[slug]/+page.svelte

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,6 @@
3939
afterNavigate(async () => {
4040
const will_delete = previous_files.some((file) => !(file.name in data.exercise.a));
4141
42-
console.log({ will_delete });
43-
4442
if (data.exercise.path !== path || will_delete) paused = true;
4543
await reset($files);
4644

src/routes/tutorial/[slug]/Chrome.svelte

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script>
22
import { createEventDispatcher } from 'svelte';
33
import refresh from '$lib/icons/refresh.svg';
4+
import terminal from '$lib/icons/terminal.svg';
45
56
/** @type {string} */
67
export let path;
@@ -24,6 +25,14 @@
2425
dispatch('change', { value: e.currentTarget.value });
2526
}}
2627
/>
28+
29+
<button
30+
disabled={loading}
31+
on:click={() => dispatch('toggle_terminal')}
32+
aria-label="toggle terminal"
33+
>
34+
<img src={terminal} alt="Terminal icon" />
35+
</button>
2736
</div>
2837

2938
<style>
@@ -38,6 +47,7 @@
3847
padding: 0.8rem;
3948
box-sizing: border-box;
4049
background: var(--sk-back-4);
50+
user-select: none;
4151
}
4252
4353
.chrome button img {
@@ -47,7 +57,7 @@
4757
transform: none;
4858
}
4959
50-
.chrome button:active img {
60+
.chrome button[aria-label='reload']:active img {
5161
transform: rotate(-360deg);
5262
transition: none;
5363
}

src/routes/tutorial/[slug]/Output.svelte

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { browser, dev } from '$app/environment';
55
import Chrome from './Chrome.svelte';
66
import Loading from './Loading.svelte';
7-
import { base, error, progress, subscribe } from './adapter';
7+
import { base, error, logs, progress, subscribe } from './adapter';
88
99
/** @type {import('$lib/types').Exercise} */
1010
export let exercise;
@@ -16,6 +16,7 @@
1616
let iframe;
1717
let loading = true;
1818
let initial = true;
19+
let terminal_visible = false;
1920
2021
// reset `path` to `exercise.path` each time, but allow it to be controlled by the iframe
2122
let path = exercise.path;
@@ -82,6 +83,9 @@
8283
on:refresh={() => {
8384
set_iframe_src($base + path);
8485
}}
86+
on:toggle_terminal={() => {
87+
terminal_visible = !terminal_visible;
88+
}}
8589
on:change={(e) => {
8690
if ($base) {
8791
const url = new URL(e.detail.value, $base);
@@ -99,6 +103,12 @@
99103
{#if paused || loading || $error}
100104
<Loading {initial} error={$error} progress={$progress.value} status={$progress.text} />
101105
{/if}
106+
107+
<div class="terminal" class:visible={terminal_visible}>
108+
{#each $logs as log}
109+
<div>{@html log}</div>
110+
{/each}
111+
</div>
102112
</div>
103113
104114
<style>
@@ -122,4 +132,41 @@
122132
border: none;
123133
background: var(--sk-back-2);
124134
}
135+
136+
.terminal {
137+
position: absolute;
138+
left: 0;
139+
bottom: 0;
140+
width: 100%;
141+
height: 80%;
142+
font-family: var(--font-mono);
143+
font-size: var(--sk-text-xs);
144+
padding: 1rem;
145+
background: white;
146+
border-top: 1px solid var(--sk-back-3);
147+
transform: translate(0, 100%);
148+
transition: transform 0.3s;
149+
}
150+
151+
.terminal::after {
152+
--thickness: 6px;
153+
--shadow: transparent;
154+
content: '';
155+
display: block;
156+
position: absolute;
157+
width: 100%;
158+
height: var(--thickness);
159+
left: 0;
160+
top: calc(-1 * var(--thickness));
161+
background-image: linear-gradient(to bottom, transparent, var(--shadow));
162+
pointer-events: none;
163+
}
164+
165+
.terminal.visible {
166+
transform: none;
167+
}
168+
169+
.terminal.visible::after {
170+
--shadow: rgba(0, 0, 0, 0.05);
171+
}
125172
</style>

src/routes/tutorial/[slug]/adapter.js

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,23 @@ export const base = writable(null);
1212
/** @type {import('svelte/store').Writable<Error | null>} */
1313
export const error = writable(null);
1414

15+
/** @type {import('svelte/store').Writable<string[]>} */
16+
export const logs = writable([]);
17+
1518
/** @type {Promise<import('$lib/types').Adapter>} */
1619
let ready = new Promise(() => {});
1720

1821
if (browser) {
1922
ready = new Promise(async (fulfil, reject) => {
2023
try {
2124
const module = await import('$lib/client/adapters/webcontainer/index.js');
22-
const adapter = await module.create(base, error, progress);
25+
const adapter = await module.create(base, error, progress, logs);
2326

2427
fulfil(adapter);
2528
} catch (error) {
2629
reject(error);
2730
}
28-
})
31+
});
2932
}
3033

3134
/** @typedef {'reload'} EventName */
@@ -34,27 +37,27 @@ if (browser) {
3437
let subscriptions = new Map([['reload', new Set()]]);
3538

3639
/**
37-
*
38-
* @param {EventName} event
39-
* @param {() => void} callback
40+
*
41+
* @param {EventName} event
42+
* @param {() => void} callback
4043
*/
4144
export function subscribe(event, callback) {
4245
subscriptions.get(event)?.add(callback);
4346

44-
return () =>{
47+
return () => {
4548
subscriptions.get(event)?.delete(callback);
4649
};
4750
}
4851

4952
/**
50-
* @param {EventName} event
53+
* @param {EventName} event
5154
*/
5255
function publish(event) {
53-
subscriptions.get(event)?.forEach(fn => fn());
56+
subscriptions.get(event)?.forEach((fn) => fn());
5457
}
5558

5659
/**
57-
* @param {import('$lib/types').Stub[]} files
60+
* @param {import('$lib/types').Stub[]} files
5861
*/
5962
export async function reset(files) {
6063
try {
@@ -81,4 +84,4 @@ export async function update(file) {
8184
if (should_reload) {
8285
publish('reload');
8386
}
84-
}
87+
}

0 commit comments

Comments
 (0)