first commit
This commit is contained in:
296
design_handoff_helder_workbench/design/src/app.jsx
Normal file
296
design_handoff_helder_workbench/design/src/app.jsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/* 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 />);
|
||||
Reference in New Issue
Block a user