PI / src /components /Workspace.tsx
noodcon's picture
Upload 64 files
9b9eafc verified
Raw
History Blame Contribute Delete
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>
);
}