Antonio De Lucreziis 2 years ago
parent 8d951b0e80
commit 0896799b34

13
package-lock.json generated

@ -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"

@ -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",

@ -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])
}

@ -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) {

@ -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 => {

@ -1,3 +1,18 @@
export const Editor = ({}) => {
return <textarea class="code-editor-prototype" spellcheck="false" autocomplete="off"></textarea>
import { useEffect } from 'preact/hooks'
export const Editor = ({ code, setCode }) => {
return (
<textarea
class="code-editor-prototype"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck={false}
value={code.value}
onInput={e => {
code.value = e.target.value
setCode?.(e.target.value)
}}
></textarea>
)
}

@ -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 (
<div class="ide">
<div class="sidebar">
<div class="logo">PHC / Run</div>
<div class="flex-row">
<button class="icon">
<div class="material-symbols-outlined">home</div>
</button>
<div class="project-details flex-grow">
<div class="title">{uuid.value}</div>
<div class="open">
<div class="material-symbols-outlined">unfold_more</div>
</div>
</div>
</div>
<div class="section">
<div class="spoiler">
<div class="material-symbols-outlined">account_tree</div>
<div class="title">Files</div>
<div class="material-symbols-outlined">keyboard_arrow_down</div>
</div>
<div class="content">
<TreeView
listDir={async path => {
const entries = await fetchJson(
`/api/container/${uuid.value}/exec/ls/${stripPrefix(path, '/')}`
)
return entries
}}
actionOpenFile={async path => {
const content = await fetchJson(
`/api/container/${uuid.value}/fs/${stripPrefix(path, '/')}`
)
console.log('content', content)
batch(() => {
tabs.value = [
...tabs.value,
{
id: path,
content: signal(content),
},
]
activeTab.value = tabs.value.length - 1
})
}}
rootPath={'/'}
/>
</div>
</div>
<div class="section">
<div class="spoiler">
<div class="material-symbols-outlined">chat</div>
<div class="title">Conversation</div>
<div class="material-symbols-outlined">keyboard_arrow_down</div>
</div>
<div class="content">...</div>
</div>
<div class="section">
<div class="spoiler">
<div class="material-symbols-outlined">settings</div>
<div class="title">Settings</div>
<div class="material-symbols-outlined">keyboard_arrow_down</div>
</div>
</div>
<div class="spacer"></div>
<div class="user-details">
<div class="profile">
<img
src={`https://api.dicebear.com/9.x/identicon/svg?seed=${uuid.value}`}
alt="profile picture"
/>
</div>
<div class="username">{'aziis98'}</div>
<div class="email">{'aziis98'}@exmaple.com</div>
</div>
</div>
<div class="header">
<div class="search">
<div class="material-symbols-outlined">search</div>
<input type="text" placeholder="Search a snippet..." />
</div>
<div class="actions">
<button class="icon">
<div class="material-symbols-outlined">terminal</div>
</button>
<button
class="run"
onClick={() => {
console.log('Running file:', activePath.value)
if (!activePath.value) return
const fullPath = '/project' + activePath.value
if (fullPath.endsWith('.sh')) {
ws.value.send(`sh ${fullPath}\n`)
return
}
if (fullPath.endsWith('.js')) {
ws.value.send(`node ${fullPath}\n`)
return
}
}}
>
<div class="material-symbols-outlined">play_arrow</div>
Run File
</button>
</div>
</div>
<div class="tabbed-editor">
<div class="tabs">
{tabs.value.map((tab, index) => (
<div
class={clsx('tab', activeTab.value === index && 'active')}
onClick={() => (activeTab.value = index)}
>
<div class="title">{tab.id.split('/').at(-1)}</div>
<div class="buttons">
<button
class="flat icon"
onClick={e => {
e.stopPropagation()
batch(() => {
const newValues = [...tabs.value]
newValues.splice(index, 1)
console.log(
newValues.length,
activeTab.value,
clamp(0, activeTab.value, newValues.length - 1)
)
tabs.value = newValues
activeTab.value =
newValues.length === 0
? null
: clamp(
0,
activeTab.value > index
? activeTab.value - 1
: activeTab.value,
newValues.length - 1
)
})
}}
>
<div class="material-symbols-outlined">close</div>
</button>
</div>
</div>
))}
</div>
<div class="editor">
{activeContent.value === null ? (
<>No file opened</>
) : (
<Editor
code={activeContent.value}
setCode={async source => {
activeContent.value.value = source
const path = activePath.value
await fetchJson(`/api/container/${uuid.value}/fs/${stripPrefix(path, '/')}`, {
method: 'PUT',
body: source,
})
}}
/>
)}
</div>
</div>
<div class="terminal">
<div class="title">Terminal</div>
<div class="actions">
<button class="flat icon">
<div class="material-symbols-outlined">delete</div>
</button>
<button class="flat icon">
<div class="material-symbols-outlined">close</div>
</button>
</div>
<div class="content" xterm-dimensions>
<Terminal ws={ws.value} />
</div>
</div>
<div class="status">Websocket connection active</div>
</div>
)
}

@ -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 (
<div
class="terminal"
ref={el => {
console.log('Starting terminal...')
term.open(el)
setTimeout(() => {
fitAddon.fit()
}, 500)
}}
></div>
)
})

@ -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 ?? '<unknown>'}"`)
}
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 => (
<TreeViewNode
listDir={listDir}
actionOpenFile={actionOpenFile}
node={entry}
depth={0}
path={'/' + entry.name}
/>
))
}
if (node.type === 'folder') {
const [collapsed, setCollapsed] = useState(true)
const [children, setChildren] = useState([])
useEffect(async () => {
setChildren(collapsed ? [] : await listDir(path))
}, [collapsed])
return (
<>
<div
class="entry folder"
style={{ '--depth': depth }}
title={path}
onClick={() => setCollapsed(c => !c)}
>
<div class="material-symbols-outlined">folder</div>
<div class="name">{node.name}/</div>
<div class="actions"></div>
</div>
{!collapsed &&
children.flatMap(entry => (
<TreeViewNode
listDir={listDir}
actionOpenFile={actionOpenFile}
node={entry}
depth={depth + 1}
path={path + '/' + entry.name}
/>
))}
</>
)
}
if (node.type === 'file') {
return (
<div
class="entry file"
style={{ '--depth': depth }}
title={path}
onClick={() => {
console.log('open', path)
actionOpenFile?.(path)
}}
>
<div class="material-symbols-outlined">description</div>
<div class="name">{node.name}</div>
<div class="actions"></div>
</div>
)
}
}
const ICONS = {
['folder']: 'folder',
['file']: 'description',
}
export const TreeView = ({ value }) => {
const flatNodes = flattenTree(value)
export const TreeView = ({ listDir, actionOpenFile, rootPath }) => {
return (
<div class="tree-view">
{flatNodes.map(node => (
<div class={['entry', node.type].join(' ')} style={{ '--depth': node.depth }}>
<div class="material-symbols-outlined">{ICONS[node.type]}</div>
<div class="name">{node.name}</div>
<div class="actions"></div>
</div>
))}
<TreeViewNode
listDir={listDir}
actionOpenFile={actionOpenFile}
node={{ type: 'root' }}
depth={0}
path={rootPath}
/>
</div>
)
}

@ -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 = (<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 = (<any>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
}
}

@ -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%;
}
}
}

@ -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)

@ -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)
}

@ -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)
---
<div class="ide">
<div class="sidebar">
<div class="logo">PHC / Run</div>
<div class="flex-row">
<button class="icon">
<div class="material-symbols-outlined">home</div>
</button>
<div class="project-details flex-grow">
<div class="title">
{projectName}
</div>
<div class="open">
<div class="material-symbols-outlined">unfold_more</div>
</div>
</div>
</div>
<div class="section">
<div class="spoiler">
<div class="material-symbols-outlined">account_tree</div>
<div class="title">Files</div>
<div class="material-symbols-outlined">keyboard_arrow_down</div>
</div>
<div class="content">
<TreeView client:load value={EXAMPLE_TREE} />
</div>
</div>
<div class="section">
<div class="spoiler">
<div class="material-symbols-outlined">chat</div>
<div class="title">Conversation</div>
<div class="material-symbols-outlined">keyboard_arrow_down</div>
</div>
<div class="content">...</div>
</div>
<div class="section">
<div class="spoiler">
<div class="material-symbols-outlined">settings</div>
<div class="title">Settings</div>
<div class="material-symbols-outlined">keyboard_arrow_down</div>
</div>
</div>
<div class="spacer"></div>
<div class="user-details">
<div class="profile">
<img src={`https://avatar.iran.liara.run/username?username=${userName}`} alt="profile picture" />
</div>
<div class="username">{userName}</div>
<div class="email">{userName}@exmaple.com</div>
</div>
</div>
<div class="header">
<div class="search">
<div class="material-symbols-outlined">search</div>
<input type="text" placeholder="Search a snippet..." />
</div>
<div class="actions">
<button class="icon">
<div class="material-symbols-outlined">terminal</div>
</button>
<button class="run">
<div class="material-symbols-outlined">play_arrow</div>
Run
</button>
</div>
</div>
<div class="tabbed-editor">
<div class="tabs">
<div class="tab active">
<div class="title">main.c</div>
<div class="buttons">
<button class="flat icon">
<div class="material-symbols-outlined">close</div>
</button>
</div>
</div>
<div class="tab">
<div class="title">data.csv</div>
<div class="buttons">
<button class="flat icon">
<div class="material-symbols-outlined">close</div>
</button>
</div>
</div>
</div>
<div class="editor">
<Editor client:load />
</div>
</div>
<div class="terminal">
<div class="title">Terminal</div>
<div class="actions">
<button class="flat icon">
<div class="material-symbols-outlined">delete</div>
</button>
<button class="flat icon">
<div class="material-symbols-outlined">close</div>
</button>
</div>
<div class="content">
<pre><code>{`/ # ls`}</code></pre>
</div>
</div>
<div class="status">Websocket connection active</div>
</div>
<!-- <IdePreact client:only="preact" userName={userName} projectName={projectName} /> -->

@ -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'

@ -0,0 +1,10 @@
---
import Base from '@/layouts/Base.astro'
import { Ide } from '@/client/components/Ide.jsx'
import '@xterm/xterm/css/xterm.css'
---
<Base>
<Ide client:only="preact" />
</Base>