Files
helder/design_handoff_helder_workbench/design/src/app.jsx
2026-06-15 09:43:01 +02:00

297 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference, status bar */
function Splitter({ orientation = "v", onDelta }) {
const [drag, setDrag] = useState(false);
function down(e) {
e.preventDefault();
let last = { x: e.clientX, y: e.clientY };
setDrag(true);
document.body.style.cursor = orientation === "v" ? "col-resize" : "row-resize";
document.body.style.userSelect = "none";
function mv(ev) {
onDelta(ev.clientX - last.x, ev.clientY - last.y);
last = { x: ev.clientX, y: ev.clientY };
}
function up() {
setDrag(false);
document.body.style.cursor = ""; document.body.style.userSelect = "";
document.removeEventListener("mousemove", mv); document.removeEventListener("mouseup", up);
}
document.addEventListener("mousemove", mv); document.addEventListener("mouseup", up);
}
return <div className={"splitter" + (orientation === "h" ? " h" : "") + (drag ? " drag" : "")} onMouseDown={down} />;
}
function RightColumn({ width }) {
const [topFrac, setTopFrac] = useState(0.52);
const ref = useRef(null);
function delta(dx, dy) {
const h = ref.current ? ref.current.clientHeight : 600;
setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h)));
}
const agent = useMemo(() => agentSeed(), []);
const shell = useMemo(() => shellSeed(), []);
return (
<div className="col right-col" style={{ width, flex: "0 0 " + width + "px" }}>
<div ref={ref} style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<div style={{ flex: "0 0 " + (topFrac * 100) + "%", minHeight: 0, display: "flex" }}>
<Terminal kind="agent" seed={agent} />
</div>
<Splitter orientation="h" onDelta={delta} />
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>
<Terminal kind="shell" seed={shell} />
</div>
</div>
</div>
);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function ancestors(path) {
const parts = path.split("/"); const out = [];
for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join("/"));
return out;
}
function initialOpenDirs(node, set) {
if (node.type === "dir") {
if (node.open && node.path) set.add(node.path);
(node.children || []).forEach((c) => initialOpenDirs(c, set));
}
return set;
}
function App() {
const changeMap = useMemo(() => Object.fromEntries(PROJECT.changes.map((c) => [c.path, c.status])), []);
const changeSet = useMemo(() => new Set(PROJECT.changes.map((c) => c.path)), []);
const [tabs, setTabs] = useState([
{ path: "src/Http/Controller/UserController.php" },
{ path: "public/assets/app.js" },
{ path: "src/types/api.ts" },
]);
const [active, setActive] = useState("src/Http/Controller/UserController.php");
const [tabMode, setTabMode] = useState({ "src/Http/Controller/UserController.php": "diff" });
const [openDirs, setOpenDirs] = useState(() => initialOpenDirs(PROJECT.tree, new Set()));
const [cursor, setCursor] = useState({ path: "src/Http/Controller/UserController.php", line: 1, col: 1 });
const [selection, setSelection] = useState(null);
const [overlay, setOverlay] = useState(null);
const [menu, setMenu] = useState(null);
const [toasts, setToasts] = useState([]);
const [splitFor, setSplitFor] = useState(null);
const [staged, setStaged] = useState(() => new Set(["src/Service/PaymentService.php", "config/app.json"]));
const [committed, setCommitted] = useState(() => new Set());
const [commitMsg, setCommitMsg] = useState("");
const [passPopup, setPassPopup] = useState(null);
const [gitW, setGitW] = useState(232);
const [treeW, setTreeW] = useState(244);
const [rightW, setRightW] = useState(444);
function toast(title, ref) {
const id = lid();
setToasts((t) => [...t, { id, title, ref }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2300);
}
async function copyText(text, label) {
try { await navigator.clipboard.writeText(text); }
catch (e) {
const ta = document.createElement("textarea"); ta.value = text;
ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta);
ta.select(); try { document.execCommand("copy"); } catch (_) {} ta.remove();
}
toast(label || "Copied reference", text);
}
const toggleDir = useCallback((p) => {
setOpenDirs((s) => { const n = new Set(s); n.has(p) ? n.delete(p) : n.add(p); return n; });
}, []);
function reveal(path) {
setOpenDirs((s) => { const n = new Set(s); ancestors(path).forEach((a) => n.add(a)); return n; });
}
const stage = (p) => setStaged((s) => { const n = new Set(s); n.add(p); return n; });
const unstage = (p) => setStaged((s) => { const n = new Set(s); n.delete(p); return n; });
const stageAll = () => setStaged(new Set(PROJECT.changes.filter((c) => !committed.has(c.path)).map((c) => c.path)));
const unstageAll = () => setStaged(new Set());
function commit() {
const list = PROJECT.changes.filter((c) => staged.has(c.path) && !committed.has(c.path));
if (!list.length || !commitMsg.trim()) return;
setCommitted((prev) => { const n = new Set(prev); list.forEach((c) => n.add(c.path)); return n; });
setStaged(new Set());
const msg = commitMsg.trim();
setCommitMsg("");
toast(`Committed ${list.length} file${list.length > 1 ? "s" : ""}`, msg.length > 34 ? msg.slice(0, 34) + "…" : msg);
}
function openFile(path, opts = {}) {
const changed = !!PROJECT.diffs[path];
setTabs((t) => t.some((x) => x.path === path) ? t : [...t, { path }]);
setActive(path);
setTabMode((m) => ({ ...m, [path]: opts.diff && changed ? "diff" : (m[path] || (changed ? "diff" : "code")) }));
reveal(path);
if (opts.line) {
// show the current/updated file so line numbers map to search hits
setSplitFor(null);
setTabMode((m) => ({ ...m, [path]: changed ? "updated" : "code" }));
setCursor({ path, line: opts.line, col: 1 });
setSelection(null);
setTimeout(() => {
const row = document.querySelector('.editor .ln-row[data-line="' + opts.line + '"]');
if (row) { const ed = row.closest(".editor"); const er = ed.getBoundingClientRect(), rr = row.getBoundingClientRect(); ed.scrollTop += (rr.top - er.top) - ed.clientHeight / 2; }
}, 70);
}
}
function closeTab(path) {
setTabs((t) => {
const ix = t.findIndex((x) => x.path === path);
const next = t.filter((x) => x.path !== path);
if (path === active) {
const fallback = next[ix] || next[ix - 1] || next[next.length - 1];
setActive(fallback ? fallback.path : null);
}
return next;
});
}
// ---- context menus ----
function openMenu(e, target) {
e.preventDefault(); e.stopPropagation();
const sparkSend = (ref) => ({ icon: Icon.spark({}), label: "Send reference to agent", onClick: () => { window.dispatchEvent(new CustomEvent("agentPaste", { detail: ref })); toast("Passed to agent", ref); } });
if (target.kind === "editor") {
const ref = target.sel ? `${target.path}:${target.sel.start}-${target.sel.end}` : `${target.path}:${target.line}`;
const mx = e.clientX, my = e.clientY;
setMenu({
x: mx, y: my, note: ref,
items: [
{ primary: true, icon: Icon.copy({}), label: "Copy reference", onClick: () => copyText(ref) },
{ icon: Icon.spark({}), label: "Pass on to Agent", onClick: () => setPassPopup({ x: mx, y: my, ref }) },
],
});
} else {
const isDir = target.kind === "dir";
const ref = isDir ? target.path + "/" : target.path;
const name = target.path.split("/").pop();
const items = [
{ primary: true, icon: Icon.copy({}), label: "Copy reference", onClick: () => copyText(ref) },
sparkSend(ref),
{ icon: Icon.copy({}), label: isDir ? "Copy folder path" : "Copy file name", onClick: () => copyText(isDir ? target.path : name, "Copied") },
];
if (!isDir) {
items.push({ sep: true });
if (target.kind === "git") {
const isStaged = staged.has(target.path);
items.push(isStaged
? { icon: Icon.minus({}), label: "Unstage changes", onClick: () => unstage(target.path) }
: { icon: Icon.plus({}), label: "Stage changes", onClick: () => stage(target.path) });
items.push({ icon: Icon.diff({}), label: "Open diff", onClick: () => openFile(target.path, { diff: true }) });
}
items.push({ icon: Icon.file({}), label: "Open file", onClick: () => openFile(target.path) });
items.push({ icon: Icon.reveal({}), label: "Reveal in Explorer", onClick: () => reveal(target.path) });
}
setMenu({ x: e.clientX, y: e.clientY, note: ref, items });
}
}
// ---- shortcuts ----
useEffect(() => {
function onKey(e) {
const meta = e.metaKey || e.ctrlKey;
if (meta && e.key.toLowerCase() === "f") { e.preventDefault(); setOverlay("search"); }
else if (meta && e.key.toLowerCase() === "w") { e.preventDefault(); if (active) closeTab(active); }
else if (e.key === "Escape") { if (splitFor) setSplitFor(null); else { setOverlay(null); setMenu(null); } }
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [active, splitFor]);
const MODE_LABEL = { original: "orig", updated: "upd", diff: "diff", code: "" };
const MODE_WORD = { original: "Original", updated: "Updated", diff: "Diff" };
const resolvedTabs = tabs.map((t) => {
const changed = !!PROJECT.diffs[t.path];
const m = tabMode[t.path] || (changed ? "diff" : "code");
return { ...t, changed, modeLabel: splitFor === t.path ? "split" : MODE_LABEL[m] };
});
const mode = tabMode[active] || (PROJECT.diffs[active] ? "diff" : "code");
const totals = PROJECT.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 });
const activeLang = active ? HL.langLabel(active) : "";
const crumb = active ? active.split("/") : [];
return (
<div className="app">
{/* title bar */}
<div className="titlebar">
<div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div>
<div className="tb-title">{Icon.spark({ style: { color: "var(--accent)" } })}<b>Helder</b><span style={{ color: "var(--fg-3)" }}></span><span style={{ color: "var(--fg-2)" }}>{PROJECT.name}</span></div>
{active && (
<div className="tb-crumb">
{crumb.map((s, i) => (<React.Fragment key={i}>{i > 0 && <span className="seg"> </span>}<span style={i === crumb.length - 1 ? { color: "var(--fg-1)" } : null}>{s}</span></React.Fragment>))}
</div>
)}
<div className="tb-spacer" />
<div className="tb-actions">
<button className="tb-btn" onClick={() => setOverlay("search")}>{Icon.search({})} Search <kbd>F</kbd></button>
</div>
</div>
{/* workbench */}
<div className="workbench">
<div className="col" style={{ width: gitW, flex: "0 0 " + gitW + "px" }}>
<GitPanel changes={PROJECT.changes} staged={staged} committed={committed}
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
onStage={stage} onUnstage={unstage} onStageAll={stageAll} onUnstageAll={unstageAll} onCommit={commit}
onOpen={openFile} onContext={openMenu} activePath={active} />
</div>
<Splitter onDelta={(dx) => setGitW((w) => clamp(w + dx, 160, 460))} />
<div className="col" style={{ width: treeW, flex: "0 0 " + treeW + "px" }}>
<FileTree tree={PROJECT.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
onContext={openMenu} activePath={active} changeMap={changeMap} committed={committed} />
</div>
<Splitter onDelta={(dx) => setTreeW((w) => clamp(w + dx, 160, 520))} />
<div className="col editor-col">
<Editor tabs={resolvedTabs} active={active} mode={mode}
setMode={(m) => { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); }}
onActivate={setActive} onClose={closeTab} onContext={openMenu}
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection} />
</div>
<Splitter onDelta={(dx) => setRightW((w) => clamp(w - dx, 280, 780))} />
<RightColumn width={rightW} />
</div>
{/* status bar */}
<div className="statusbar">
<div className="sb accent">{Icon.branch({ width: 12, height: 12 })}<span style={{ color: "#0c1320" }}>{PROJECT.branch}</span></div>
<div className="sb"><span className="a">+{totals.add}</span> <span className="d">{totals.del}</span></div>
<div className="sb spacer" />
{active && <div className="sb">{selection && selection.path === active && selection.start !== selection.end ? `${selection.end - selection.start + 1} lines selected` : `Ln ${cursor.path === active ? cursor.line : 1}, Col ${cursor.path === active ? cursor.col : 1}`}</div>}
{active && <div className="sb">Spaces: 4</div>}
{active && <div className="sb">UTF-8</div>}
{active && <div className="sb"><b>{activeLang}</b></div>}
{active && PROJECT.diffs[active] && <div className="sb">{splitFor === active ? "Split" : (MODE_WORD[mode] || "")}</div>}
</div>
{/* overlays */}
{splitFor && <SplitView path={splitFor} onClose={() => setSplitFor(null)} onContext={openMenu} />}
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref}
onConfirm={(text) => {
const line = (text && text.trim() ? text.trim() + " " : "") + passPopup.ref;
window.dispatchEvent(new CustomEvent("agentPaste", { detail: line }));
setPassPopup(null);
toast("Passed to agent", passPopup.ref);
}}
onCancel={() => setPassPopup(null)} />}
{overlay === "search" && <SearchModal onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />}
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
<Toasts toasts={toasts} />
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);