119 lines
3.8 KiB
TypeScript
119 lines
3.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import type { FileEntry, FileReadResponse } from '@hermes-mobile/shared';
|
|
import { useCompanion } from '../../api/CompanionContext.js';
|
|
|
|
function parentPath(path: string): string {
|
|
if (path === '.') return '.';
|
|
const parts = path.split('/').filter(Boolean);
|
|
parts.pop();
|
|
return parts.length ? parts.join('/') : '.';
|
|
}
|
|
|
|
export function FilesScreen() {
|
|
const { client } = useCompanion();
|
|
const [path, setPath] = useState('.');
|
|
const [entries, setEntries] = useState<FileEntry[]>([]);
|
|
const [activeFile, setActiveFile] = useState<FileReadResponse | null>(null);
|
|
const [draft, setDraft] = useState('');
|
|
const [status, setStatus] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
async function loadDirectory(nextPath = path): Promise<void> {
|
|
setBusy(true);
|
|
setStatus('');
|
|
|
|
try {
|
|
const response = await client.listFiles(nextPath);
|
|
setPath(response.path);
|
|
setEntries(response.entries);
|
|
setActiveFile(null);
|
|
setDraft('');
|
|
} catch (caughtError) {
|
|
setStatus(caughtError instanceof Error ? caughtError.message : 'Unable to list files');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function openEntry(entry: FileEntry): Promise<void> {
|
|
if (entry.type === 'directory') {
|
|
await loadDirectory(entry.path);
|
|
return;
|
|
}
|
|
|
|
setBusy(true);
|
|
setStatus('');
|
|
|
|
try {
|
|
const response = await client.readFile(entry.path);
|
|
setActiveFile(response);
|
|
setDraft(response.content);
|
|
} catch (caughtError) {
|
|
setStatus(caughtError instanceof Error ? caughtError.message : 'Unable to read file');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function saveFile(): Promise<void> {
|
|
if (!activeFile) return;
|
|
setBusy(true);
|
|
|
|
try {
|
|
const response = await client.writeFile(activeFile.path, draft);
|
|
setStatus(`Saved ${response.bytesWritten} bytes to ${response.path}`);
|
|
await openEntry({ name: activeFile.path, path: activeFile.path, type: 'file', size: 0, modifiedAt: new Date().toISOString() });
|
|
} catch (caughtError) {
|
|
setStatus(caughtError instanceof Error ? caughtError.message : 'Unable to save file');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadDirectory('.');
|
|
}, [client]);
|
|
|
|
return (
|
|
<section className="screen stack-screen">
|
|
<div className="screen-heading">
|
|
<div>
|
|
<p className="eyebrow">Workspace</p>
|
|
<h2>Files</h2>
|
|
</div>
|
|
<button className="small-button" onClick={() => void loadDirectory()} type="button">Refresh</button>
|
|
</div>
|
|
|
|
<article className="panel-card">
|
|
<div className="path-bar">
|
|
<button disabled={path === '.' || busy} onClick={() => void loadDirectory(parentPath(path))} type="button">Up</button>
|
|
<span>{path}</span>
|
|
</div>
|
|
<div className="file-list">
|
|
{entries.map((entry) => (
|
|
<button className="file-row" key={entry.path} onClick={() => void openEntry(entry)} type="button">
|
|
<span>{entry.type === 'directory' ? '📁' : '📄'} {entry.name}</span>
|
|
<small>{entry.type === 'file' ? `${entry.size} B` : 'dir'}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
{activeFile ? (
|
|
<article className="panel-card editor-card">
|
|
<div className="screen-heading">
|
|
<div>
|
|
<p className="eyebrow">Editing</p>
|
|
<h3>{activeFile.path}</h3>
|
|
</div>
|
|
<button className="small-button" disabled={busy} onClick={() => void saveFile()} type="button">Save</button>
|
|
</div>
|
|
<textarea aria-label="File content" onChange={(event) => setDraft(event.target.value)} value={draft} />
|
|
</article>
|
|
) : null}
|
|
|
|
{status ? <p className="error-card">{status}</p> : null}
|
|
</section>
|
|
);
|
|
}
|