mvp
parent
8d951b0e80
commit
0896799b34
@ -1,3 +1,18 @@
|
|||||||
export const Editor = ({}) => {
|
import { useEffect } from 'preact/hooks'
|
||||||
return <textarea class="code-editor-prototype" spellcheck="false" autocomplete="off"></textarea>
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
Reference in New Issue