agent-manager-template / web /src /components /FolderPicker.tsx
lvwerra's picture
lvwerra HF Staff
Polish sidebar creation and terminal headers
cae0460
Raw
History Blame Contribute Delete
4.28 kB
import { useEffect, useState } from 'react';
import * as api from '../api';
import { FolderGlyph } from './icons';
const join = (a: string, b: string) => (a ? `${a}/${b}` : b);
const ROOT = '.';
const isRoot = (p: string) => p === '' || p === ROOT;
// Folder names are typed freely; just strip separators and traversal.
const cleanName = (s: string) => s.replace(/[/\\]/g, '').replace(/\.\./g, '').trim();
// Inline "+ new folder" row shown at the end of every level, so a fresh
// subfolder anywhere in the tree is one click + type away.
function NewFolderRow({ base, depth, onPick }: {
base: string; depth: number; onPick: (p: string) => void;
}) {
const [adding, setAdding] = useState(false);
const [name, setName] = useState('');
const commit = () => {
const n = cleanName(name);
if (n) onPick(join(base, n));
setAdding(false); setName('');
};
if (!adding) {
return (
<button className="fp-row fp-new" style={{ paddingLeft: `${depth * 14 + 8}px` }} onClick={() => setAdding(true)}>
+ new folder…
</button>
);
}
return (
<div className="fp-row" style={{ paddingLeft: `${depth * 14 + 8}px` }}>
<input
autoFocus className="fp-input" placeholder="folder name" value={name}
onChange={(e) => setName(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') { setAdding(false); setName(''); } }}
/>
</div>
);
}
// One level of the workspace folder tree: click a name to pick it as the
// location, click the caret to expand its subfolders.
function Level({ path, depth, value, onPick }: {
path: string; depth: number; value: string; onPick: (p: string) => void;
}) {
const [folders, setFolders] = useState<string[] | null>(null);
const [open, setOpen] = useState<Set<string>>(new Set());
useEffect(() => {
let alive = true;
api.listFolders(path).then((r) => { if (alive) setFolders(r.folders); }).catch(() => { if (alive) setFolders([]); });
return () => { alive = false; };
}, [path]);
if (!folders) return <div className="fp-row fp-msg" style={{ paddingLeft: `${depth * 14 + 8}px` }}></div>;
return (
<>
{folders.map((f) => {
const p = join(path, f);
const expanded = open.has(f);
return (
<div key={f}>
<div className={`fp-row${value === p ? ' picked' : ''}`} style={{ paddingLeft: `${depth * 14 + 8}px` }}>
<button
className="fp-caret"
aria-label={expanded ? 'Collapse' : 'Expand'}
onClick={() => setOpen((s) => { const n = new Set(s); n.has(f) ? n.delete(f) : n.add(f); return n; })}
>{expanded ? '▾' : '▸'}</button>
<FolderGlyph className="fp-ico" open={expanded} />
<button className="fp-name" onClick={() => onPick(p)}>{f}</button>
</div>
{expanded && <Level path={p} depth={depth + 1} value={value} onPick={onPick} />}
</div>
);
})}
<NewFolderRow base={path} depth={depth} onPick={onPick} />
</>
);
}
/**
* Workspace location picker. `value` is a workspace-relative path; '.' is the
* workspace root. Picking a not-yet-existing folder is fine: the server
* creates it when the agent is created.
*/
export default function FolderPicker({ value, onChange }: {
value: string;
onChange: (p: string) => void;
}) {
const [open, setOpen] = useState(false);
const root = isRoot(value);
return (
<div className="fp">
<button className="fp-current" onClick={() => setOpen((o) => !o)} title="Choose where this agent runs">
<FolderGlyph className="fp-ico" open={open || root} />
<span className="fp-path">{root ? 'workspaces' : value}</span>
<span className="fp-toggle">{open ? '▾' : '▸'}</span>
</button>
{open && (
<div className="fp-tree">
<button className={`fp-row fp-root${root ? ' picked' : ''}`} onClick={() => onChange(ROOT)}>
<FolderGlyph className="fp-ico" open />
<span>workspaces</span>
</button>
<Level path="" depth={0} value={root ? ROOT : value} onPick={(p) => { onChange(p); }} />
</div>
)}
</div>
);
}