mvp
parent
8d951b0e80
commit
0896799b34
@ -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>
|
||||
)
|
||||
})
|
||||
@ -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