| import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; |
| import type { ShellEntry } from '../client/useAgent'; |
| import { iconFor, shortPath } from '../lib/format'; |
| import { makeZip } from '../lib/zip'; |
|
|
| interface Props { |
| files: Record<string, string>; |
| shell: ShellEntry[]; |
| busy: string | null; |
| onSave: (path: string, content: string) => Promise<boolean>; |
| onRun: (command: string) => Promise<void>; |
| onClearShell: () => void; |
| onError: (message: string) => void; |
| } |
|
|
| const MAX_BYTES = 1024 ** 2; |
|
|
| export function WorkspacePanel({ files, shell, busy, onSave, onRun, onClearShell, onError }: Props) { |
| const paths = Object.keys(files).sort(); |
| const [selected, setSelected] = useState<string | null>(null); |
| const [text, setText] = useState(''); |
| const [dirty, setDirty] = useState(false); |
| const [adding, setAdding] = useState(false); |
| const [newName, setNewName] = useState(''); |
| const [command, setCommand] = useState(''); |
| const history = useRef<string[]>([]); |
| const cursor = useRef(-1); |
| const outRef = useRef<HTMLDivElement>(null); |
|
|
| |
| useEffect(() => { |
| if (paths.length === 0) return; |
| if (selected === null || !(selected in files)) { |
| setSelected(paths.find((p) => p.endsWith('README.md')) ?? paths[0]); |
| setDirty(false); |
| } |
| }, [paths.join('|')]); |
|
|
| |
| useEffect(() => { |
| if (selected !== null && !dirty) setText(files[selected] ?? ''); |
| }, [selected, files, dirty]); |
|
|
| useEffect(() => { |
| const el = outRef.current; |
| if (el) el.scrollTop = el.scrollHeight; |
| }, [shell]); |
|
|
| const save = async () => { |
| if (selected === null) return; |
| if (await onSave(selected, text)) setDirty(false); |
| }; |
|
|
| const choose = (path: string) => { |
| if (dirty && !window.confirm('Discard unsaved changes to this file?')) return; |
| setSelected(path); |
| setDirty(false); |
| }; |
|
|
| const create = async (e: { preventDefault(): void }) => { |
| e.preventDefault(); |
| const name = newName.trim().replace(/^\/+/, ''); |
| if (!name) return; |
| const path = '/workspace/' + name; |
| if (await onSave(path, '')) { |
| setSelected(path); |
| setDirty(false); |
| setAdding(false); |
| setNewName(''); |
| } |
| }; |
|
|
| const importFiles = async (list: FileList | null) => { |
| for (const f of Array.from(list ?? [])) { |
| if (f.size > MAX_BYTES) { |
| onError(`${f.name} is larger than 1 MiB.`); |
| continue; |
| } |
| await onSave('/workspace/' + f.name, await f.text()); |
| } |
| }; |
|
|
| const exportZip = () => { |
| const url = URL.createObjectURL(makeZip(files)); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = 'pi-workspace.zip'; |
| a.click(); |
| setTimeout(() => URL.revokeObjectURL(url), 1000); |
| }; |
|
|
| const run = async () => { |
| const cmd = command.trim(); |
| if (!cmd) return; |
| history.current.push(cmd); |
| cursor.current = history.current.length; |
| setCommand(''); |
| await onRun(cmd); |
| }; |
|
|
| const onShellKey = (e: KeyboardEvent<HTMLTextAreaElement>) => { |
| if (e.nativeEvent.isComposing) return; |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| void run(); |
| } else if (e.key === 'ArrowUp' && !command.includes('\n') && cursor.current > 0) { |
| e.preventDefault(); |
| cursor.current -= 1; |
| setCommand(history.current[cursor.current]); |
| } else if (e.key === 'ArrowDown' && !command.includes('\n') && cursor.current >= 0) { |
| e.preventDefault(); |
| cursor.current += 1; |
| setCommand(history.current[cursor.current] ?? ''); |
| } else if (e.key === 'l' && e.ctrlKey) { |
| e.preventDefault(); |
| onClearShell(); |
| } |
| }; |
|
|
| return ( |
| <aside className="workspace" aria-label="Workspace"> |
| <div className="ws-head"> |
| <h2>Workspace</h2> |
| <div> |
| <button className="quiet" onClick={() => setAdding((v) => !v)}>+ File</button> |
| <label className="quiet" style={{ display: 'contents' }}> |
| <button className="quiet" onClick={(e) => (e.currentTarget.nextElementSibling as HTMLInputElement).click()}>Import</button> |
| <input type="file" multiple hidden onChange={(e) => { void importFiles(e.target.files); e.target.value = ''; }} /> |
| </label> |
| <button className="quiet" onClick={exportZip} disabled={paths.length === 0}>Export</button> |
| </div> |
| </div> |
| |
| <div className="files-area"> |
| <nav className="files" aria-label="Files"> |
| {adding && ( |
| <form className="addfile" onSubmit={create}> |
| <label className="sr" htmlFor="newfile">File name</label> |
| <input id="newfile" type="text" autoFocus placeholder="notes.md" value={newName} onChange={(e) => setNewName(e.target.value)} /> |
| </form> |
| )} |
| {paths.map((p) => ( |
| <button key={p} className="file quiet" aria-current={p === selected} onClick={() => choose(p)} title={p}> |
| <img src={`${import.meta.env.BASE_URL}file-icons/${iconFor(p)}.svg`} alt="" /> |
| <span>{shortPath(p)}</span> |
| </button> |
| ))} |
| </nav> |
| <div className="editor"> |
| <div className="editor-head"> |
| <span>{selected ? shortPath(selected) + (dirty ? ' •' : '') : 'Select a file'}</span> |
| <button className="quiet" onClick={save} disabled={!dirty || busy !== null}>Save</button> |
| </div> |
| <label className="sr" htmlFor="editor">File contents</label> |
| <textarea |
| id="editor" |
| wrap="off" |
| spellCheck={false} |
| autoCapitalize="off" |
| autoComplete="off" |
| value={text} |
| disabled={selected === null} |
| placeholder="Select a file to inspect or edit it." |
| onChange={(e) => { setText(e.target.value); setDirty(true); }} |
| onKeyDown={(e) => { |
| if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); void save(); } |
| }} |
| /> |
| </div> |
| </div> |
| |
| <details className="shell" open> |
| <summary>Shell · just-bash · /workspace</summary> |
| <div className="shell-body"> |
| <div className="shell-out" ref={outRef} role="log" aria-label="Shell output" tabIndex={0}> |
| {shell.length === 0 && <span className="code">Try <code>ls -la</code> or <code>cat README.md</code>.</span>} |
| {shell.map((s) => ( |
| <div key={s.id}> |
| <pre className="cmd">$ {s.command}</pre> |
| {s.result.stdout && <pre>{s.result.stdout}</pre>} |
| {s.result.stderr && <pre className="stderr">{s.result.stderr}</pre>} |
| {s.result.exitCode !== 0 && <pre className="code">exit {s.result.exitCode}</pre>} |
| </div> |
| ))} |
| </div> |
| <form className="shell-form" onSubmit={(e) => { e.preventDefault(); void run(); }}> |
| <span aria-hidden="true">$</span> |
| <label className="sr" htmlFor="command">Shell command</label> |
| <textarea id="command" rows={1} spellCheck={false} autoCapitalize="off" autoComplete="off" placeholder="Enter a shell command…" |
| value={command} onChange={(e) => setCommand(e.target.value)} onKeyDown={onShellKey} /> |
| <button className="primary" type="submit" disabled={busy !== null || !command.trim()}>Run</button> |
| </form> |
| </div> |
| </details> |
| <p className="note">Bash, text tools and a virtual filesystem. Node.js, Python and network commands are unavailable.</p> |
| </aside> |
| ); |
| } |
|
|