From 0896799b3445f079328f080b0b2508e204da2f66 Mon Sep 17 00:00:00 2001 From: Antonio De Lucreziis Date: Sat, 21 Sep 2024 18:39:31 +0200 Subject: [PATCH] mvp --- package-lock.json | 13 ++ package.json | 3 + server/docker.js | 21 ++- server/server.js | 56 +++++- server/utils.js | 11 +- src/client/components/Editor.jsx | 19 +- src/client/components/Ide.jsx | 268 +++++++++++++++++++++++++++++ src/client/components/Terminal.jsx | 53 ++++++ src/client/components/TreeView.jsx | 110 ++++++++++-- src/client/customFit.ts | 89 ++++++++++ src/client/style.css | 44 +++-- src/client/term.js | 10 +- src/client/utils.js | 21 +++ src/components/Ide.astro | 110 +----------- src/layouts/Base.astro | 4 +- src/pages/@container/new.astro | 10 ++ 16 files changed, 692 insertions(+), 150 deletions(-) create mode 100644 src/client/components/Ide.jsx create mode 100644 src/client/components/Terminal.jsx create mode 100644 src/client/customFit.ts create mode 100644 src/client/utils.js create mode 100644 src/pages/@container/new.astro diff --git a/package-lock.json b/package-lock.json index eafefad..f61a671 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,12 +13,15 @@ "@fontsource-variable/fira-code": "^5.1.0", "@fontsource-variable/inter": "^5.1.0", "@fontsource-variable/material-symbols-outlined": "^5.1.0", + "@fontsource/jetbrains-mono": "^5.1.0", + "@preact/signals": "^1.3.0", "@uiw/codemirror-theme-github": "^4.23.2", "@uiw/react-codemirror": "^4.23.2", "@uiw/react-monacoeditor": "^3.6.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "astro": "^4.15.6", + "clsx": "^2.1.1", "codemirror": "^5.65.17", "express": "^4.21.0", "node-pty": "^1.0.0", @@ -603,6 +606,12 @@ "integrity": "sha512-t5lXmVtDZDha0iYp9//cwaftAjHrcLmWeWi6NILWsV/SvpQ8vnRQbVxMK7VIxHJy3czm4diHYA7I8w6MmqbLfQ==", "license": "Apache-2.0" }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.1.0.tgz", + "integrity": "sha512-YzWpGYY3PoqvmvwW2Hb4NwkN5C7LO0FmZugEGcFe6cNNE/cMgH9JhW5LI6hjG+1+nwgSHyJtKmblHNrUqlyhWg==", + "license": "OFL-1.1" + }, "node_modules/@img/sharp-libvips-linux-x64": { "version": "1.0.4", "cpu": [ @@ -815,6 +824,8 @@ }, "node_modules/@preact/signals": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.3.0.tgz", + "integrity": "sha512-EOMeg42SlLS72dhoq6Vjq08havnLseWmPQ8A0YsgIAqMgWgx7V1a39+Pxo6i7SY5NwJtH4849JogFq3M67AzWg==", "license": "MIT", "dependencies": { "@preact/signals-core": "^1.7.0" @@ -1868,6 +1879,8 @@ }, "node_modules/clsx": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { "node": ">=6" diff --git a/package.json b/package.json index addb029..15443e7 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,15 @@ "@fontsource-variable/fira-code": "^5.1.0", "@fontsource-variable/inter": "^5.1.0", "@fontsource-variable/material-symbols-outlined": "^5.1.0", + "@fontsource/jetbrains-mono": "^5.1.0", + "@preact/signals": "^1.3.0", "@uiw/codemirror-theme-github": "^4.23.2", "@uiw/react-codemirror": "^4.23.2", "@uiw/react-monacoeditor": "^3.6.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "astro": "^4.15.6", + "clsx": "^2.1.1", "codemirror": "^5.65.17", "express": "^4.21.0", "node-pty": "^1.0.0", diff --git a/server/docker.js b/server/docker.js index c370ffe..28b7673 100644 --- a/server/docker.js +++ b/server/docker.js @@ -31,7 +31,7 @@ async function cleanContainers() { } async function createContainer(tag) { - const id = randomUUID() + const id = randomUUID().split('-').at(-1) await fs.mkdir(getContainerPath('phc', id), { recursive: true }) @@ -50,6 +50,21 @@ async function createContainer(tag) { containerIds.add(id) await storeContainers() + await runCommandContainer(id, `echo "echo hello world" > example.sh; chmod +x example.sh`) + + // Example file tree + + await runCommandContainer(id, `mkdir -p /project/src /project/bin`) + + await runCommandContainer(id, `echo 'console.log("Hello from index.js")' > /project/src/index.js`) + await runCommandContainer(id, `echo 'console.log("Hello from app.js")' > /project/src/app.js`) + + await runCommandContainer(id, `echo 'echo "Hello from script.sh"' > /project/bin/script.sh`) + await runCommandContainer(id, `chmod +x /project/bin/script.sh`) + + await runCommandContainer(id, `echo 'This is the README file.' > /project/README.md`) + await runCommandContainer(id, `echo '{"name": "project", "version": "1.0.0"}' > /project/package.json`) + return id } @@ -87,3 +102,7 @@ async function destroyContainer(id) { containerIds.delete(id) await storeContainers() } + +async function runCommandContainer(uuid, command) { + return await runCommand('docker', ['exec', '-w', '/project', uuid, '/bin/sh', '-c', command]) +} diff --git a/server/server.js b/server/server.js index fe69be6..8d8e290 100644 --- a/server/server.js +++ b/server/server.js @@ -8,18 +8,59 @@ import fs from 'fs/promises' const app = express() -app.use(express.json()) +app.use(express.json({ strict: false })) -app.get('/api/status', c => c.json(42)) +// TODO: Convert to spawn + websocket +app.get('/api/container/:uuid([a-zA-Z0-9-]+)/exec/shell', async (req, res) => { + const uuid = req.params.uuid + + /** @type {string} */ + const command = JSON.parse(req.query.command) + + try { + const output = await runCommand('docker', ['exec', uuid, '/bin/sh', '-c', command]) + res.json(output) + } catch (e) { + res.json({ error: e.toString() }) + } +}) + +// app.get('/api/container/:uuid([a-zA-Z0-9-]+)/exec/ls/*', async (req, res) => { +// const uuid = req.params.uuid +// const p = path.normalize(req.params[0]) + +// try { +// const files = await runCommand('/bin/ls', [path.join(getContainerPath('phc', uuid), p)]) + +// res.json(files.trim().split('\n')) +// } catch (e) { +// res.json({ error: e.toString() }) +// } +// }) app.get('/api/container/:uuid([a-zA-Z0-9-]+)/exec/ls/*', async (req, res) => { const uuid = req.params.uuid const p = path.normalize(req.params[0]) try { - const files = await runCommand('/bin/ls', [path.join(getContainerPath('phc', uuid), p)]) + const dirPath = path.join(getContainerPath('phc', uuid), p) + const fileList = await fs.readdir(dirPath) + + const result = await Promise.all( + fileList.map(async name => { + const fullPath = path.join(dirPath, name) + const stats = await fs.lstat(fullPath) + + return { + type: stats.isDirectory() ? 'folder' : 'file', + name, + } + }) + ) - res.json(files.trim().split('\n')) + result.sort((e1, e2) => e2.type.localeCompare(e1.type)) + + res.json(result) } catch (e) { res.json({ error: e.toString() }) } @@ -40,13 +81,18 @@ app.get('/api/container/:uuid([a-zA-Z0-9-]+)/fs/*', async (req, res) => { }) app.put('/api/container/:uuid([a-zA-Z0-9-]+)/fs/*', async (req, res) => { + const uuid = req.params.uuid const p = path.normalize(req.params[0]) const content = req.body console.log(content) try { - await fs.writeFile(path.join(getContainerPath('phc', uuid), p), content) + // await fs.writeFile(path.join(getContainerPath('phc', uuid), p), content) + + await runCommand('docker', ['exec', '-i', uuid, '/usr/bin/tee', `/project/${p}`], { + stdin: content, + }) res.json('ok') } catch (e) { diff --git a/server/utils.js b/server/utils.js index 5247e4d..0b435aa 100644 --- a/server/utils.js +++ b/server/utils.js @@ -4,7 +4,10 @@ export const CONTAINERS_ROOT = '/tmp/phc-run/containers' export const getContainerPath = (ns, uuid) => `${CONTAINERS_ROOT}/${ns}/${uuid}` -export function runCommand(command, args, options = { stdin: null, capture: ['stdout', 'stderr'] }) { +export function runCommand(command, args, options = {}) { + options.stdin ??= null + options.capture ??= ['stdout', 'stderr'] + console.log(`Running: ${command} ${JSON.stringify(args)}`) return new Promise((resolve, reject) => { @@ -14,18 +17,24 @@ export function runCommand(command, args, options = { stdin: null, capture: ['st if (options.capture.includes('stdout')) { child.stdout.on('data', data => { + console.log('[command]', '[stdout]', JSON.stringify(data.toString())) output += data.toString() }) } if (options.capture.includes('stderr')) { child.stderr.on('data', data => { + console.log('[command]', '[stderr]', JSON.stringify(data.toString())) output += data.toString() }) } if (options.stdin) { + console.log('[command]', '[stdin]', options.stdin) + + child.stdin.setDefaultEncoding('utf8') child.stdin.write(options.stdin) + child.stdin.end() } child.on('close', code => { diff --git a/src/client/components/Editor.jsx b/src/client/components/Editor.jsx index 082a9a0..70c7632 100644 --- a/src/client/components/Editor.jsx +++ b/src/client/components/Editor.jsx @@ -1,3 +1,18 @@ -export const Editor = ({}) => { - return +import { useEffect } from 'preact/hooks' + +export const Editor = ({ code, setCode }) => { + return ( + + ) } diff --git a/src/client/components/Ide.jsx b/src/client/components/Ide.jsx new file mode 100644 index 0000000..8aa8b89 --- /dev/null +++ b/src/client/components/Ide.jsx @@ -0,0 +1,268 @@ +import { useSignal, signal, useComputed, batch, effect } from '@preact/signals' + +import { TreeView, EXAMPLE_TREE } from '@/client/components/TreeView.jsx' +import { Editor } from '@/client/components/Editor.jsx' +import clsx from 'clsx' +import { useEffect, useState } from 'preact/hooks' +import { fetchJson } from '../utils.js' +import { Terminal } from './Terminal.jsx' + +function ensurePrefix(s, prefix) { + return s.slice(0, prefix.length) === prefix ? s : prefix + s +} + +function stripPrefix(s, prefix) { + return s.slice(0, prefix.length) === prefix ? s.slice(prefix.length) : s +} + +function clamp(min, value, max) { + return Math.max(min, Math.min(value, max)) +} + +export const Ide = ({}) => { + const ws = useSignal(null) + const uuid = useSignal(null) + + useEffect(() => { + ws.value = new WebSocket(`ws://${location.host}/api/docker/node:current-alpine/pty`) + ws.value.addEventListener('message', e => { + const event = JSON.parse(e.data) + if (event.type === 'created') { + uuid.value = event.id + history.pushState(event.id, '', '/@container/' + event.id) + } + }) + }, []) + + if (!uuid.value) return <>Creating container... + + const activeTab = useSignal(null) + + const tabs = useSignal([ + // { + // id: '/project/main.c', + // content: signal('...main.c...'), + // }, + // { + // id: '/project/data.csv', + // content: signal('...data.csv...'), + // }, + ]) + + const activePath = useComputed(() => { + if (activeTab.value === null) return null + + return tabs.value[activeTab.value].id + }) + + const activeContent = useComputed(() => { + if (activeTab.value === null) return null + + return tabs.value[activeTab.value].content + }) + + effect(() => { + const path = activePath.value + const contentSig = activeContent.value + + if (contentSig === null || path === null) return + + console.log(path, contentSig.value) + }) + + return ( +
+ +
+ +
+ + +
+
+
+
+ {tabs.value.map((tab, index) => ( +
(activeTab.value = index)} + > +
{tab.id.split('/').at(-1)}
+
+ +
+
+ ))} +
+
+ {activeContent.value === null ? ( + <>No file opened + ) : ( + { + activeContent.value.value = source + + const path = activePath.value + + await fetchJson(`/api/container/${uuid.value}/fs/${stripPrefix(path, '/')}`, { + method: 'PUT', + body: source, + }) + }} + /> + )} +
+
+
+
Terminal
+
+ + +
+
+ +
+
+
Websocket connection active
+
+ ) +} diff --git a/src/client/components/Terminal.jsx b/src/client/components/Terminal.jsx new file mode 100644 index 0000000..daaea56 --- /dev/null +++ b/src/client/components/Terminal.jsx @@ -0,0 +1,53 @@ +import { useMemo } from 'preact/hooks' +import { memo } from 'preact/compat' + +import { Terminal as XTerm } from '@xterm/xterm' + +import { Piatto_Light as Theme } from 'xterm-theme' +import { useEventListener } from '../utils.js' +import { FitAddon } from '../customFit' + +export const Terminal = memo(({ ws }) => { + const [fitAddon, term] = useMemo(() => { + const term = new XTerm({ + theme: Theme, + }) + + const fitAddon = new FitAddon() + term.loadAddon(fitAddon) + + term.onData(data => { + ws.send(data) + }) + + return [fitAddon, term] + }, []) + + useEventListener(ws, 'message', e => { + const event = JSON.parse(e.data) + + if (event.type === 'pty') { + term.write(event.data) + } + }) + + useEventListener(ws, 'close', () => { + console.log('Disconnected from backend container') + term.write('\r\nDisconnected from backend container') + }) + + return ( +
{ + console.log('Starting terminal...') + + term.open(el) + + setTimeout(() => { + fitAddon.fit() + }, 500) + }} + >
+ ) +}) diff --git a/src/client/components/TreeView.jsx b/src/client/components/TreeView.jsx index 43a86aa..a3062c7 100644 --- a/src/client/components/TreeView.jsx +++ b/src/client/components/TreeView.jsx @@ -1,3 +1,7 @@ +import { useSignal } from '@preact/signals' +import clsx from 'clsx' +import { useEffect, useState } from 'preact/hooks' + export const EXAMPLE_TREE = { type: 'root', children: [ @@ -22,48 +26,122 @@ export const EXAMPLE_TREE = { ], } -const flattenTree = (node, depth = 0) => { - if (node.type === 'root') return node.children.flatMap(entry => flattenTree(entry, depth)) - +const flattenTree = (node, depth = 0, path = []) => { + if (node.type === 'root') { + return node.children.flatMap(entry => flattenTree(entry, depth, [])) + } if (node.type === 'folder') return [ { depth, type: 'folder', name: node.name, + path: path.join('/') + '/', }, - ...node.children.flatMap(entry => flattenTree(entry, depth + 1)), + ...node.children.flatMap(entry => flattenTree(entry, depth + 1, [...path, node.name])), ] - - if (node.type === 'file') + if (node.type === 'file') { return [ { depth, type: 'file', name: node.name, + path: path.join('/'), }, ] + } throw new Error(`invalid node type "${node?.type ?? ''}"`) } +const TreeViewNode = ({ listDir, actionOpenFile, node, depth, path }) => { + if (node.type === 'root') { + const [children, setChildren] = useState([]) + + useEffect(async () => { + setChildren(await listDir(path)) + }, []) + + return children.flatMap(entry => ( + + )) + } + + if (node.type === 'folder') { + const [collapsed, setCollapsed] = useState(true) + + const [children, setChildren] = useState([]) + + useEffect(async () => { + setChildren(collapsed ? [] : await listDir(path)) + }, [collapsed]) + + return ( + <> +
setCollapsed(c => !c)} + > +
folder
+
{node.name}/
+
+
+ {!collapsed && + children.flatMap(entry => ( + + ))} + + ) + } + + if (node.type === 'file') { + return ( +
{ + console.log('open', path) + actionOpenFile?.(path) + }} + > +
description
+
{node.name}
+
+
+ ) + } +} + const ICONS = { ['folder']: 'folder', ['file']: 'description', } -export const TreeView = ({ value }) => { - const flatNodes = flattenTree(value) - +export const TreeView = ({ listDir, actionOpenFile, rootPath }) => { return (
- {flatNodes.map(node => ( -
-
{ICONS[node.type]}
-
{node.name}
-
-
- ))} +
) } diff --git a/src/client/customFit.ts b/src/client/customFit.ts new file mode 100644 index 0000000..a71e38a --- /dev/null +++ b/src/client/customFit.ts @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Terminal, type ITerminalAddon } from '@xterm/xterm' + +interface ITerminalDimensions { + /** + * The number of rows in the terminal. + */ + rows: number + + /** + * The number of columns in the terminal. + */ + cols: number +} + +export class FitAddon implements ITerminalAddon { + private _terminal: Terminal | undefined + + constructor() {} + + public activate(terminal: Terminal): void { + this._terminal = terminal + try { + this.fit() + } catch (error) { + console.log(error) + } + } + + public dispose(): void {} + + public fit(): void { + const dims = this.proposeDimensions() + if (!dims || !this._terminal) { + return + } + + // TODO: Remove reliance on private API + // const core: any = (this._terminal)._core + + // Force a full render + if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) { + // core._renderCoordinator.clear() + this._terminal.resize(dims.cols, dims.rows) + } + } + + public proposeDimensions(): ITerminalDimensions | undefined { + if (!this._terminal) { + return undefined + } + + if (!this._terminal.element.parentElement) { + return undefined + } + + const dimEl = this._terminal.element.closest('[xterm-dimensions]') ?? this._terminal.element.parentElement + + // TODO: Remove reliance on private API + const core = (this._terminal)._core + + const parentElementStyle = window.getComputedStyle(dimEl) + const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height')) + const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width'))) + + console.log(parentElementWidth, parentElementHeight) + + const elementStyle = window.getComputedStyle(this._terminal.element) + const elementPadding = { + top: parseInt(elementStyle.getPropertyValue('padding-top')), + bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')), + right: parseInt(elementStyle.getPropertyValue('padding-right')), + left: parseInt(elementStyle.getPropertyValue('padding-left')), + } + const elementPaddingVer = elementPadding.top + elementPadding.bottom + const elementPaddingHor = elementPadding.right + elementPadding.left + const availableHeight = parentElementHeight - elementPaddingVer + const availableWidth = parentElementWidth - elementPaddingHor + const geometry = { + cols: Math.floor(availableWidth / core._renderService.dimensions.device.cell.width), + rows: Math.floor(availableHeight / core._renderService.dimensions.device.cell.height), + } + return geometry + } +} diff --git a/src/client/style.css b/src/client/style.css index 172ea23..8b9b9da 100644 --- a/src/client/style.css +++ b/src/client/style.css @@ -58,16 +58,22 @@ body { } .xterm { - margin: 2rem; + /* margin: 2rem; padding: 0.5rem; border-radius: 0.5rem; overflow: clip; - box-shadow: 0 0 2rem 0 #0003; + box-shadow: 0 0 2rem 0 #0003; */ &.xterm-dom-renderer-owner-1 .xterm-rows { + font-size: 14px; font-family: 'JetBrains Mono', monospace; } + + * { + font-variant-ligatures: none; + font-variant-ligatures: no-common-ligatures; + } } textarea.code-editor-prototype { @@ -80,9 +86,10 @@ textarea.code-editor-prototype { width: 100%; height: 100%; - font-family: 'Fira Code Variable', monospace; - font-weight: 400; + font-family: 'JetBrains Mono', monospace; font-size: 15px; + font-weight: 300; + line-height: 1.35; } input[type='text'] { @@ -281,7 +288,7 @@ input[type='submit'], height: 100%; grid-template-columns: 20rem 1fr; - grid-template-rows: 3rem 1fr 33vh auto; + grid-template-rows: 3.25rem 1fr 33vh auto; grid-template-areas: 'sidebar header' @@ -362,7 +369,7 @@ input[type='submit'], gap: 1rem; - padding: 0 0.5rem; + padding: 0 0.75rem; border-bottom: 1px solid #d9d9d9; @@ -433,7 +440,10 @@ input[type='submit'], padding: 0.35rem; - font-size: 15px; + min-width: 150px; + margin-left: -1px; + + font-size: 13px; font-weight: 450; z-index: 1; @@ -447,6 +457,8 @@ input[type='submit'], padding: 0 0 0 0.15rem; } + border-bottom: 1px solid #fff; + &:not(.active) { border-bottom: 1px solid #d9d9d9; @@ -454,10 +466,6 @@ input[type='submit'], background: #f8f8f8; } } - - &:not(:first-child) { - border-left: none; - } } } @@ -505,6 +513,20 @@ input[type='submit'], > .content { font-family: 'JetBrains Mono', monospace; font-size: 14px; + + display: grid; + overflow: hidden; + + width: 100%; + height: 100%; + + > .terminal { + display: grid; + overflow: hidden; + + width: 100%; + height: 100%; + } } } diff --git a/src/client/term.js b/src/client/term.js index e444487..a4c47cf 100644 --- a/src/client/term.js +++ b/src/client/term.js @@ -43,9 +43,9 @@ ws.addEventListener('close', () => { term.write('\r\nDisconnected from backend container') }) -setInterval(async () => { - const req = await fetch(`/api/container/${id}/exec/ls/`) - const files = await req.json() +// setInterval(async () => { +// const req = await fetch(`/api/container/${id}/exec/ls/`) +// const files = await req.json() - console.log(files) -}, 1000) +// console.log(files) +// }, 1000) diff --git a/src/client/utils.js b/src/client/utils.js new file mode 100644 index 0000000..6c1b955 --- /dev/null +++ b/src/client/utils.js @@ -0,0 +1,21 @@ +import { useEffect } from 'preact/hooks' + +export async function fetchJson(url, options = {}) { + if (options.hasOwnProperty('method')) { + options.headers = { 'Content-Type': 'application/json' } + options.body = JSON.stringify(options.body) + } + + const res = await fetch(url, options) + return await res.json() +} + +export function useEventListener(target, event, handler, deps = []) { + useEffect(() => { + target.addEventListener(event, handler) + + return () => { + target.removeEventListener(event, handler) + } + }, deps) +} diff --git a/src/components/Ide.astro b/src/components/Ide.astro index 377b776..3fbff45 100644 --- a/src/components/Ide.astro +++ b/src/components/Ide.astro @@ -1,6 +1,5 @@ --- -import { TreeView, EXAMPLE_TREE } from '@/client/components/TreeView.jsx' -import { Editor } from '@/client/components/Editor.jsx' +import { Ide as IdePreact } from '@/client/components/Ide.jsx' const { project } = Astro.props @@ -8,109 +7,4 @@ const userName = project.split('/').at(0).slice(1) const projectName = project.split('/').at(-1) --- -
- -
- -
- - -
-
-
-
-
-
main.c
-
- -
-
-
-
data.csv
-
- -
-
-
-
- -
-
-
-
Terminal
-
- - -
-
-
{`/ # ls`}
-
-
-
Websocket connection active
-
+ diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 3d3e8e5..9da26ca 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -1,7 +1,9 @@ --- import '@fontsource-variable/material-symbols-outlined/full.css' import '@fontsource-variable/inter/index.css' -import '@fontsource-variable/fira-code/index.css' + +import '@fontsource/jetbrains-mono/latin.css' +import '@fontsource/jetbrains-mono/latin-ext.css' import '@/client/style.css' diff --git a/src/pages/@container/new.astro b/src/pages/@container/new.astro new file mode 100644 index 0000000..f52717f --- /dev/null +++ b/src/pages/@container/new.astro @@ -0,0 +1,10 @@ +--- +import Base from '@/layouts/Base.astro' +import { Ide } from '@/client/components/Ide.jsx' + +import '@xterm/xterm/css/xterm.css' +--- + + + +