Download src/components/Workspace.tsx from noodcon/PI: direct link, hf CLI and curl.
- Browser
- Download file 7.79 kB
-
https://huggingface.co/spaces/noodcon/PI/resolve/main/src/components/Workspace.tsx
- Command line
-
hf download hf://spaces/noodcon/PI/src/components/Workspace.tsx
-
curl -L -o Workspace.tsx https://huggingface.co/spaces/noodcon/PI/resolve/main/src/components/Workspace.tsx
7.79 kB
| 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); | |
| // Chọn file đầu tiên khi có dữ liệu, hoặc khi file đang chọn bị xoá. | |
| 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('|')]); // eslint-disable-line react-hooks/exhaustive-deps | |
| // Đồng bộ nội dung editor khi agent/shell đổi file (không ghi đè nếu bạn đang sửa dở). | |
| 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> | |
| ); | |
| } | |