297 lines
14 KiB
JavaScript
297 lines
14 KiB
JavaScript
/* 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 />);
|