first commit
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Helder — AI Code Workbench</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
|
||||
<!-- Prism (manual highlighting) -->
|
||||
<script>window.Prism = window.Prism || {}; window.Prism.manual = true;</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup-templating.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-php.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script>
|
||||
|
||||
<!-- React + Babel (pinned) -->
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<!-- data + helpers (plain JS) -->
|
||||
<script src="src/data.js"></script>
|
||||
<script src="src/highlight.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- components (JSX) -->
|
||||
<script type="text/babel" src="src/components.jsx"></script>
|
||||
<script type="text/babel" src="src/editor.jsx"></script>
|
||||
<script type="text/babel" src="src/terminals.jsx"></script>
|
||||
<script type="text/babel" src="src/overlays.jsx"></script>
|
||||
<script type="text/babel" src="src/app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
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 />);
|
||||
186
design_handoff_helder_workbench/design/src/components.jsx
Normal file
186
design_handoff_helder_workbench/design/src/components.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/* Shared icons, FileIcon, GitPanel, FileTree */
|
||||
const { useState, useEffect, useRef, useMemo, useCallback } = React;
|
||||
|
||||
/* ---- minimal geometric icons ---- */
|
||||
const Icon = {
|
||||
search: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.4"/><line x1="10.5" y1="10.5" x2="14" y2="14" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>),
|
||||
branch: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><circle cx="4" cy="3.5" r="1.8" stroke="currentColor" strokeWidth="1.3"/><circle cx="4" cy="12.5" r="1.8" stroke="currentColor" strokeWidth="1.3"/><circle cx="12" cy="5" r="1.8" stroke="currentColor" strokeWidth="1.3"/><path d="M4 5.3v5.4M5.8 5C9 5 10 6.2 10 9v0" stroke="currentColor" strokeWidth="1.3" fill="none"/></svg>),
|
||||
close: (p) => (<svg width="11" height="11" viewBox="0 0 12 12" fill="none" {...p}><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>),
|
||||
copy: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="5" y="5" width="8" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.3"/><path d="M3 11V3a1 1 0 0 1 1-1h6" stroke="currentColor" strokeWidth="1.3" fill="none"/></svg>),
|
||||
terminal: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4l3 3-3 3M8 11h5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
||||
spark: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 1.5l1.6 4.9L14.5 8l-4.9 1.6L8 14.5 6.4 9.6 1.5 8l4.9-1.6L8 1.5z" stroke="currentColor" strokeWidth="1.1" fill="none" strokeLinejoin="round"/></svg>),
|
||||
file: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M4 2h5l3 3v9H4V2z" stroke="currentColor" strokeWidth="1.2" fill="none"/><path d="M9 2v3h3" stroke="currentColor" strokeWidth="1.2" fill="none"/></svg>),
|
||||
reveal: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M2 4.5h4l1.3 1.5H14V13H2V4.5z" stroke="currentColor" strokeWidth="1.2" fill="none"/></svg>),
|
||||
diff: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M4 2v8M4 12.5v1.5M2 4h4M2 8h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/><path d="M12 14V6M12 3.5V2M10 12h4M10 8h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/></svg>),
|
||||
plus: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M7 2.5v9M2.5 7h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>),
|
||||
minus: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>),
|
||||
check: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7.5l2.8 3L11.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
||||
discard: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M12.5 5.5A5 5 0 1 0 13 9" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round"/><path d="M12.5 2.5v3h-3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
||||
};
|
||||
|
||||
const Chevron = ({ open }) => (
|
||||
<svg width="9" height="9" viewBox="0 0 10 10" style={{ transform: open ? "rotate(90deg)" : "none", transition: "transform .12s" }}>
|
||||
<path d="M3.5 2l3.5 3-3.5 3" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FolderIcon = ({ open }) => (
|
||||
<svg className="folder-ic" width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path d={open ? "M1.5 4.5h4l1.2 1.4H14V13H2V4.5z" : "M1.5 4.5h4l1.2 1.4H14V13H1.5V4.5z"}
|
||||
fill={open ? "rgba(122,131,140,.18)" : "rgba(122,131,140,.12)"} stroke="currentColor" strokeWidth="1.1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function FileIcon({ path }) {
|
||||
const ic = HL.iconFor(path);
|
||||
return <span className="ficon" style={{ background: ic.c }}><span>{ic.t}</span></span>;
|
||||
}
|
||||
|
||||
/* ============ Git / Source Control panel ============ */
|
||||
function GitRow({ c, staged, activePath, onOpen, onContext, onToggleStage }) {
|
||||
const name = c.path.split("/").pop();
|
||||
const dir = c.path.split("/").slice(0, -1).join("/");
|
||||
return (
|
||||
<div className={"git-row" + (activePath === c.path ? " active" : "")}
|
||||
onClick={() => onOpen(c.path, { diff: true })}
|
||||
onContextMenu={(e) => onContext(e, { path: c.path, kind: "git", staged })}
|
||||
title={c.path}>
|
||||
<span className={"git-stat " + c.status}>{c.status}</span>
|
||||
<FileIcon path={c.path} />
|
||||
<span className={"git-name" + (c.deleted ? " del" : "")}>{name}</span>
|
||||
{dir && <span className="git-dir">{dir}/</span>}
|
||||
<button className="git-act" title={staged ? "Unstage changes" : "Stage changes"}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleStage(c.path); }}>
|
||||
{staged ? Icon.minus({}) : Icon.plus({})}
|
||||
</button>
|
||||
<span className="git-delta">
|
||||
{c.add > 0 && <span className="a">+{c.add}</span>}
|
||||
{c.del > 0 && <span className="d">-{c.del}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GitPanel({ changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath }) {
|
||||
const visible = changes.filter((c) => !committed.has(c.path));
|
||||
const stagedList = visible.filter((c) => staged.has(c.path));
|
||||
const changesList = visible.filter((c) => !staged.has(c.path));
|
||||
const totals = visible.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 });
|
||||
const canCommit = stagedList.length > 0 && commitMsg.trim().length > 0;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="phead">
|
||||
{Icon.branch({})}<span>Source Control</span>
|
||||
<span className="ct">{visible.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="commit-box">
|
||||
<textarea className="commit-input" rows={1} value={commitMsg} spellCheck={false}
|
||||
placeholder="Message (⌘↵ to commit)"
|
||||
onChange={(e) => setCommitMsg(e.target.value)}
|
||||
onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && canCommit) { e.preventDefault(); onCommit(); } }} />
|
||||
<button className="commit-btn" disabled={!canCommit} onClick={onCommit}
|
||||
title={canCommit ? "Commit staged changes" : "Stage files and write a message to commit"}>
|
||||
{Icon.check({})}<span>Commit{stagedList.length ? " " + stagedList.length : ""}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="git-body">
|
||||
{visible.length === 0 ? (
|
||||
<div className="git-empty">{Icon.check({ width: 20, height: 20 })}<span>No changes — working tree clean</span></div>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<div className="git-group">
|
||||
Staged Changes <span className="gc">{stagedList.length}</span>
|
||||
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus({})}</button>}
|
||||
</div>
|
||||
{stagedList.length > 0 ? stagedList.map((c) => (
|
||||
<GitRow key={c.path} c={c} staged={true} activePath={activePath}
|
||||
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
|
||||
)) : (
|
||||
<div className="git-none">Nothing staged — use <span className="key">+</span> to stage a file</div>
|
||||
)}
|
||||
|
||||
<div className="git-divider" />
|
||||
|
||||
<div className="git-group">
|
||||
Changes <span className="gc">{changesList.length}</span>
|
||||
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus({})}</button>}
|
||||
</div>
|
||||
{changesList.length > 0 ? changesList.map((c) => (
|
||||
<GitRow key={c.path} c={c} staged={false} activePath={activePath}
|
||||
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
|
||||
)) : (
|
||||
<div className="git-none">All changes staged</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="git-foot">
|
||||
<span className="branch-chip">{Icon.branch({})}<b>{PROJECT.branch}</b></span>
|
||||
<span style={{ marginLeft: "auto", fontFamily: "var(--mono)" }}>
|
||||
<span className="a" style={{ color: "var(--add)" }}>+{totals.add}</span>{" "}
|
||||
<span className="d" style={{ color: "var(--del)" }}>-{totals.del}</span>
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ File Tree ============ */
|
||||
function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed }) {
|
||||
const pad = 10 + depth * 13;
|
||||
if (node.type === "dir") {
|
||||
const isOpen = openDirs.has(node.path) || node.path === "";
|
||||
return (
|
||||
<React.Fragment>
|
||||
{node.path !== "" && (
|
||||
<div className="tree-row folder" style={{ paddingLeft: pad }}
|
||||
onClick={() => toggleDir(node.path)}
|
||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: "dir" })}>
|
||||
<span className="tw"><Chevron open={isOpen} /></span>
|
||||
<FolderIcon open={isOpen} />
|
||||
<span className="tree-label">{node.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{isOpen && node.children.map((c) => (
|
||||
<TreeNode key={c.path} node={c} depth={node.path === "" ? 0 : depth + 1}
|
||||
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
|
||||
onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
const status = committed && committed.has(node.path) ? null : changeMap[node.path];
|
||||
return (
|
||||
<div className={"tree-row" + (activePath === node.path ? " active" : "")}
|
||||
style={{ paddingLeft: pad + 2 }}
|
||||
onClick={() => onOpen(node.path)}
|
||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: "file" })}
|
||||
title={node.path}>
|
||||
<span className="tw" />
|
||||
<FileIcon path={node.path} />
|
||||
<span className="tree-label" style={status === "D" ? { textDecoration: "line-through", color: "var(--fg-3)" } : null}>{node.name}</span>
|
||||
{status && <span className={"tree-badge " + status}>{status}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed }) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="phead">
|
||||
<span>Explorer</span>
|
||||
<span style={{ marginLeft: "auto", color: "var(--fg-3)", textTransform: "none", letterSpacing: 0, fontFamily: "var(--mono)", fontSize: 10.5 }}>{tree.name}</span>
|
||||
</div>
|
||||
<div className="tree-body">
|
||||
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
|
||||
onOpen={onOpen} onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Icon, Chevron, FolderIcon, FileIcon, GitPanel, FileTree });
|
||||
712
design_handoff_helder_workbench/design/src/data.js
Normal file
712
design_handoff_helder_workbench/design/src/data.js
Normal file
@@ -0,0 +1,712 @@
|
||||
/* Mock project: filesystem tree, file contents, before/after pairs, runtime diff. */
|
||||
(function () {
|
||||
// ---- working-tree (current / updated) file contents ----------------
|
||||
const F = {};
|
||||
|
||||
F["src/Http/Controller/UserController.php"] = `<?php
|
||||
|
||||
namespace App\\Http\\Controller;
|
||||
|
||||
use App\\Service\\PaymentService;
|
||||
use App\\Repository\\UserRepository;
|
||||
use Psr\\Http\\Message\\ResponseInterface;
|
||||
use Psr\\Http\\Message\\ServerRequestInterface;
|
||||
|
||||
final class UserController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $users,
|
||||
private readonly PaymentService $payments,
|
||||
) {}
|
||||
|
||||
public function show(ServerRequestInterface $request, string $id): ResponseInterface
|
||||
{
|
||||
$user = $this->users->find($id);
|
||||
|
||||
if ($user === null) {
|
||||
return $this->json(['error' => 'user_not_found'], 404);
|
||||
}
|
||||
|
||||
$balance = $this->payments->balanceFor($user);
|
||||
|
||||
return $this->json([
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'plan' => $user->plan->value,
|
||||
'name' => $user->name,
|
||||
'currency' => $user->currency,
|
||||
'balance' => $balance->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(ServerRequestInterface $request, string $id): ResponseInterface
|
||||
{
|
||||
$user = $this->users->find($id);
|
||||
if ($user === null) {
|
||||
return $this->json(['error' => 'user_not_found'], 404);
|
||||
}
|
||||
|
||||
$data = (array) $request->getParsedBody();
|
||||
$user->fill($this->onlyFillable($data));
|
||||
$this->users->save($user);
|
||||
|
||||
return $this->json($user->toArray());
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function onlyFillable(array $data): array
|
||||
{
|
||||
$allowed = ['email', 'plan', 'name'];
|
||||
return array_intersect_key($data, array_flip($allowed));
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
F["src/Service/PaymentService.php"] = `<?php
|
||||
|
||||
namespace App\\Service;
|
||||
|
||||
use App\\Entity\\User;
|
||||
use App\\ValueObject\\Money;
|
||||
use App\\Gateway\\PaymentGateway;
|
||||
use Psr\\Log\\LoggerInterface;
|
||||
|
||||
final class PaymentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaymentGateway $gateway,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function balanceFor(User $user): Money
|
||||
{
|
||||
$cents = $this->gateway->lookupBalance($user->id);
|
||||
return Money::fromCents($cents, $user->currency ?? 'EUR');
|
||||
}
|
||||
|
||||
public function charge(User $user, Money $amount, string $reason): bool
|
||||
{
|
||||
if ($amount->isZero()) {
|
||||
$this->logger->warning('Skipped zero charge', ['user' => $user->id]);
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $this->gateway->charge($user->paymentToken, $amount->cents());
|
||||
$this->logger->info('Charge attempt', [
|
||||
'user' => $user->id,
|
||||
'amount' => $amount->cents(),
|
||||
'ok' => $result->success,
|
||||
]);
|
||||
|
||||
return $result->success;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
F["public/assets/app.js"] = `import { createStore } from './store.js';
|
||||
import { mountRouter } from './router.js';
|
||||
|
||||
const store = createStore({
|
||||
user: null,
|
||||
notifications: [],
|
||||
theme: 'dark',
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const res = await fetch('/api/session', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const session = await res.json();
|
||||
store.set('user', session.user);
|
||||
store.set('theme', session.user.theme ?? 'dark');
|
||||
}
|
||||
|
||||
mountRouter(document.querySelector('#app'), store);
|
||||
store.subscribe('notifications', renderToasts);
|
||||
}
|
||||
|
||||
function renderToasts(list) {
|
||||
const host = document.querySelector('#toasts');
|
||||
host.replaceChildren(...list.map((n) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = \\\`toast toast--\\\${n.level}\\\`;
|
||||
el.textContent = n.message;
|
||||
return el;
|
||||
}));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', bootstrap);
|
||||
`;
|
||||
|
||||
F["public/assets/store.js"] = `export function createStore(initial = {}) {
|
||||
let state = { ...initial };
|
||||
const subs = new Map();
|
||||
|
||||
return {
|
||||
get: (key) => state[key],
|
||||
set(key, value) {
|
||||
state = { ...state, [key]: value };
|
||||
(subs.get(key) || []).forEach((fn) => fn(value, state));
|
||||
},
|
||||
subscribe(key, fn) {
|
||||
const list = subs.get(key) || [];
|
||||
list.push(fn);
|
||||
subs.set(key, list);
|
||||
return () => subs.set(key, list.filter((f) => f !== fn));
|
||||
},
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
F["public/assets/styles.css"] = `:root {
|
||||
--brand: #4d8dff;
|
||||
--ink: #15171a;
|
||||
--paper: #ffffff;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
background: var(--ink);
|
||||
color: #e6e8ea;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius);
|
||||
border-left: 3px solid var(--brand);
|
||||
}
|
||||
|
||||
.toast--error { border-left-color: #e0696a; }
|
||||
.toast--success { border-left-color: #5cbd6b; }
|
||||
`;
|
||||
|
||||
F["src/types/api.ts"] = `export type Plan = 'free' | 'pro' | 'enterprise';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
plan: Plan;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Balance {
|
||||
cents: number;
|
||||
currency: string;
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
export type ApiResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; error: string; status: number };
|
||||
|
||||
export async function getUser(id: string): Promise<ApiResult<User>> {
|
||||
const res = await fetch(\\\`/api/users/\\\${id}\\\`);
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: 'request_failed', status: res.status };
|
||||
}
|
||||
return { ok: true, data: (await res.json()) as User };
|
||||
}
|
||||
`;
|
||||
|
||||
F["scripts/migrate.py"] = `#!/usr/bin/env python3
|
||||
"""Run pending database migrations in order."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from db import connect, applied_migrations
|
||||
|
||||
MIGRATIONS = Path(__file__).parent / "migrations"
|
||||
|
||||
|
||||
def pending(conn):
|
||||
done = applied_migrations(conn)
|
||||
files = sorted(MIGRATIONS.glob("*.sql"))
|
||||
return [f for f in files if f.stem not in done]
|
||||
|
||||
|
||||
def run(conn, migration: Path) -> None:
|
||||
sql = migration.read_text()
|
||||
print(f" -> applying {migration.stem}")
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
cur.execute(
|
||||
"INSERT INTO schema_migrations (version) VALUES (%s)",
|
||||
(migration.stem,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
conn = connect()
|
||||
todo = pending(conn)
|
||||
if not todo:
|
||||
print("Database is up to date.")
|
||||
return 0
|
||||
print(f"Applying {len(todo)} migration(s)...")
|
||||
for migration in todo:
|
||||
run(conn, migration)
|
||||
print("Done.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
`;
|
||||
|
||||
F["scripts/seed.py"] = `#!/usr/bin/env python3
|
||||
"""Seed the database with demo data for local development."""
|
||||
import random
|
||||
from db import connect
|
||||
|
||||
PLANS = ["free", "pro", "enterprise"]
|
||||
|
||||
|
||||
def seed_users(conn, count: int = 25) -> None:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(count):
|
||||
cur.execute(
|
||||
"INSERT INTO users (email, plan) VALUES (%s, %s)",
|
||||
(f"user{i}@example.com", random.choice(PLANS)),
|
||||
)
|
||||
conn.commit()
|
||||
print(f"Seeded {count} users.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_users(connect())
|
||||
`;
|
||||
|
||||
F["templates/dashboard.html"] = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Dashboard</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="layout">
|
||||
<header class="topbar">
|
||||
<h1 class="logo">Console</h1>
|
||||
<nav class="nav">
|
||||
<a href="/users" class="nav__link">Users</a>
|
||||
<a href="/billing" class="nav__link">Billing</a>
|
||||
</nav>
|
||||
</header>
|
||||
<section id="content" class="content"></section>
|
||||
</main>
|
||||
<div id="toasts" class="toast-host"></div>
|
||||
<script type="module" src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
F["config/app.json"] = `{
|
||||
"name": "console",
|
||||
"env": "production",
|
||||
"features": {
|
||||
"billing": true,
|
||||
"newDashboard": true,
|
||||
"exportCsv": false
|
||||
},
|
||||
"payment": {
|
||||
"gateway": "stripe",
|
||||
"currency": "EUR",
|
||||
"retryLimit": 3
|
||||
},
|
||||
"logging": {
|
||||
"level": "info",
|
||||
"channel": "stdout"
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
F["composer.json"] = `{
|
||||
"name": "blijnder/console",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"psr/log": "^3.0",
|
||||
"psr/http-message": "^2.0",
|
||||
"nyholm/psr7": "^1.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"phpstan/phpstan": "^1.11"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": { "App\\\\": "src/" }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
F["package.json"] = `{
|
||||
"name": "console-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.3.0",
|
||||
"vitest": "^2.0.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
F["README.md"] = `# Console
|
||||
|
||||
Internal admin console. PHP API + small vanilla JS frontend.
|
||||
|
||||
## Getting started
|
||||
|
||||
composer install
|
||||
npm install
|
||||
python scripts/migrate.py
|
||||
npm run dev
|
||||
|
||||
## Layout
|
||||
|
||||
- \`src/\` PHP application code (PSR-4, \`App\\\` namespace)
|
||||
- \`public/\` Document root and frontend assets
|
||||
- \`scripts/\` Python maintenance + migration scripts
|
||||
- \`templates/\` Server-rendered HTML
|
||||
`;
|
||||
|
||||
F[".env"] = `APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
DATABASE_URL=postgres://localhost:5432/console
|
||||
PAYMENT_GATEWAY=stripe
|
||||
PAYMENT_CURRENCY=EUR
|
||||
LOG_LEVEL=info
|
||||
`;
|
||||
|
||||
// ---- ORIGINAL (pre-edit) versions of changed files ----------------
|
||||
const O = {};
|
||||
|
||||
O["src/Http/Controller/UserController.php"] = `<?php
|
||||
|
||||
namespace App\\Http\\Controller;
|
||||
|
||||
use App\\Service\\PaymentService;
|
||||
use App\\Repository\\UserRepository;
|
||||
use Psr\\Http\\Message\\ResponseInterface;
|
||||
use Psr\\Http\\Message\\ServerRequestInterface;
|
||||
|
||||
final class UserController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $users,
|
||||
private readonly PaymentService $payments,
|
||||
) {}
|
||||
|
||||
public function show(ServerRequestInterface $request, string $id): ResponseInterface
|
||||
{
|
||||
$user = $this->users->find($id);
|
||||
|
||||
if ($user === null) {
|
||||
return $this->json(['error' => 'user_not_found'], 404);
|
||||
}
|
||||
|
||||
$balance = $this->payments->balanceFor($user);
|
||||
|
||||
return $this->json([
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'plan' => $user->plan,
|
||||
'balance' => $balance->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(ServerRequestInterface $request, string $id): ResponseInterface
|
||||
{
|
||||
$user = $this->users->find($id);
|
||||
if ($user === null) {
|
||||
return $this->json(['error' => 'user_not_found'], 404);
|
||||
}
|
||||
|
||||
$data = (array) $request->getParsedBody();
|
||||
$user->fill($this->onlyFillable($data));
|
||||
$this->users->save($user);
|
||||
|
||||
return $this->json($user->toArray());
|
||||
}
|
||||
|
||||
private function onlyFillable(array $data): array
|
||||
{
|
||||
return array_intersect_key($data, array_flip(['email', 'plan']));
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
O["public/assets/app.js"] = `import { createStore } from './store.js';
|
||||
import { mountRouter } from './router.js';
|
||||
|
||||
const store = createStore({
|
||||
user: null,
|
||||
notifications: [],
|
||||
theme: 'dark',
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const res = await fetch('/api/session');
|
||||
if (res.ok) {
|
||||
const session = await res.json();
|
||||
store.set('user', session.user);
|
||||
}
|
||||
|
||||
mountRouter(document.querySelector('#app'), store);
|
||||
store.subscribe('notifications', renderToasts);
|
||||
}
|
||||
|
||||
function renderToasts(list) {
|
||||
const host = document.querySelector('#toasts');
|
||||
host.replaceChildren(...list.map((n) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = \\\`toast toast--\\\${n.level}\\\`;
|
||||
el.textContent = n.message;
|
||||
return el;
|
||||
}));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', bootstrap);
|
||||
`;
|
||||
|
||||
O["config/app.json"] = `{
|
||||
"name": "console",
|
||||
"env": "production",
|
||||
"features": {
|
||||
"billing": true,
|
||||
"newDashboard": false
|
||||
},
|
||||
"payment": {
|
||||
"gateway": "stripe",
|
||||
"currency": "EUR",
|
||||
"retryLimit": 3
|
||||
},
|
||||
"logging": {
|
||||
"level": "info",
|
||||
"channel": "stdout"
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
O["scripts/migrate.py"] = `#!/usr/bin/env python3
|
||||
"""Run pending database migrations in order."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from db import connect, applied_migrations
|
||||
|
||||
MIGRATIONS = Path(__file__).parent / "migrations"
|
||||
|
||||
|
||||
def pending(conn):
|
||||
done = applied_migrations(conn)
|
||||
files = sorted(MIGRATIONS.glob("*.sql"))
|
||||
return [f for f in files if f.stem not in done]
|
||||
|
||||
|
||||
def run(conn, migration: Path) -> None:
|
||||
sql = migration.read_text()
|
||||
print(f" -> applying {migration.stem}")
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
cur.execute(
|
||||
"INSERT INTO schema_migrations (version) VALUES (%s)",
|
||||
(migration.stem,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
conn = connect()
|
||||
todo = pending(conn)
|
||||
if not todo:
|
||||
print("Database is up to date.")
|
||||
return 0
|
||||
for migration in todo:
|
||||
run(conn, migration)
|
||||
print("Done.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
`;
|
||||
|
||||
// PaymentService is a brand-new file (added) -> original is empty
|
||||
O["src/Service/PaymentService.php"] = "";
|
||||
|
||||
// LegacyUser was deleted -> original content, no working-tree version
|
||||
O["src/Model/LegacyUser.php"] = `<?php
|
||||
|
||||
namespace App\\Model;
|
||||
|
||||
/**
|
||||
* @deprecated Superseded by App\\Entity\\User. Kept only for the
|
||||
* legacy billing import; safe to remove once the importer is gone.
|
||||
*/
|
||||
final class LegacyUser
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $id,
|
||||
public readonly string $email,
|
||||
public readonly ?string $plan = null,
|
||||
) {}
|
||||
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
return new self(
|
||||
(int) $row['id'],
|
||||
(string) $row['email'],
|
||||
$row['plan'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'email' => $this->email,
|
||||
'plan' => $this->plan,
|
||||
];
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ---- file tree (nested) -------------------------------------------
|
||||
const tree = {
|
||||
name: "console", type: "dir", path: "", open: true, children: [
|
||||
{ name: "config", type: "dir", path: "config", open: false, children: [
|
||||
{ name: "app.json", type: "file", path: "config/app.json" },
|
||||
]},
|
||||
{ name: "public", type: "dir", path: "public", open: true, children: [
|
||||
{ name: "assets", type: "dir", path: "public/assets", open: true, children: [
|
||||
{ name: "app.js", type: "file", path: "public/assets/app.js" },
|
||||
{ name: "store.js", type: "file", path: "public/assets/store.js" },
|
||||
{ name: "styles.css", type: "file", path: "public/assets/styles.css" },
|
||||
]},
|
||||
]},
|
||||
{ name: "scripts", type: "dir", path: "scripts", open: false, children: [
|
||||
{ name: "migrate.py", type: "file", path: "scripts/migrate.py" },
|
||||
{ name: "seed.py", type: "file", path: "scripts/seed.py" },
|
||||
]},
|
||||
{ name: "src", type: "dir", path: "src", open: true, children: [
|
||||
{ name: "Http", type: "dir", path: "src/Http", open: true, children: [
|
||||
{ name: "Controller", type: "dir", path: "src/Http/Controller", open: true, children: [
|
||||
{ name: "UserController.php", type: "file", path: "src/Http/Controller/UserController.php" },
|
||||
]},
|
||||
]},
|
||||
{ name: "Service", type: "dir", path: "src/Service", open: true, children: [
|
||||
{ name: "PaymentService.php", type: "file", path: "src/Service/PaymentService.php" },
|
||||
]},
|
||||
{ name: "types", type: "dir", path: "src/types", open: false, children: [
|
||||
{ name: "api.ts", type: "file", path: "src/types/api.ts" },
|
||||
]},
|
||||
]},
|
||||
{ name: "templates", type: "dir", path: "templates", open: false, children: [
|
||||
{ name: "dashboard.html", type: "file", path: "templates/dashboard.html" },
|
||||
]},
|
||||
{ name: ".env", type: "file", path: ".env" },
|
||||
{ name: "composer.json", type: "file", path: "composer.json" },
|
||||
{ name: "package.json", type: "file", path: "package.json" },
|
||||
{ name: "README.md", type: "file", path: "README.md" },
|
||||
],
|
||||
};
|
||||
|
||||
// ---- line-based LCS diff ------------------------------------------
|
||||
function buildDiff(origText, updText) {
|
||||
const a = origText === "" ? [] : origText.replace(/\n$/, "").split("\n");
|
||||
const b = updText === "" ? [] : updText.replace(/\n$/, "").split("\n");
|
||||
const n = a.length, m = b.length;
|
||||
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
|
||||
for (let i = n - 1; i >= 0; i--)
|
||||
for (let j = m - 1; j >= 0; j--)
|
||||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
|
||||
const ops = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) { ops.push({ t: "same", a: i, b: j }); i++; j++; }
|
||||
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: "del", a: i }); i++; }
|
||||
else { ops.push({ t: "add", b: j }); j++; }
|
||||
}
|
||||
while (i < n) { ops.push({ t: "del", a: i++ }); }
|
||||
while (j < m) { ops.push({ t: "add", b: j++ }); }
|
||||
|
||||
const rows = [], left = [], right = [], split = [];
|
||||
const delSet = new Set(), addSet = new Set();
|
||||
let add = 0, del = 0;
|
||||
for (const op of ops) {
|
||||
if (op.t === "same") {
|
||||
rows.push({ sign: " ", oldNo: op.a + 1, newNo: op.b + 1, text: a[op.a] });
|
||||
} else if (op.t === "del") {
|
||||
rows.push({ sign: "-", oldNo: op.a + 1, newNo: null, text: a[op.a] });
|
||||
delSet.add(op.a); del++;
|
||||
} else {
|
||||
rows.push({ sign: "+", oldNo: null, newNo: op.b + 1, text: b[op.b] });
|
||||
addSet.add(op.b); add++;
|
||||
}
|
||||
}
|
||||
a.forEach((text, idx) => left.push({ no: idx + 1, text, mark: delSet.has(idx) ? "del" : null }));
|
||||
b.forEach((text, idx) => right.push({ no: idx + 1, text, mark: addSet.has(idx) ? "add" : null }));
|
||||
|
||||
// aligned split rows (pair del/add blocks)
|
||||
let dbuf = [], abuf = [];
|
||||
const flush = () => {
|
||||
const k = Math.max(dbuf.length, abuf.length);
|
||||
for (let x = 0; x < k; x++) split.push({ l: dbuf[x] || null, r: abuf[x] || null });
|
||||
dbuf = []; abuf = [];
|
||||
};
|
||||
for (const op of ops) {
|
||||
if (op.t === "same") { flush(); split.push({ l: { no: op.a + 1, text: a[op.a] }, r: { no: op.b + 1, text: b[op.b] } }); }
|
||||
else if (op.t === "del") dbuf.push({ no: op.a + 1, text: a[op.a], mark: "del" });
|
||||
else abuf.push({ no: op.b + 1, text: b[op.b], mark: "add" });
|
||||
}
|
||||
flush();
|
||||
|
||||
return { rows, left, right, split, add, del };
|
||||
}
|
||||
|
||||
// ---- changed files -------------------------------------------------
|
||||
const changeDefs = [
|
||||
{ path: "src/Service/PaymentService.php", status: "A" },
|
||||
{ path: "src/Http/Controller/UserController.php", status: "M" },
|
||||
{ path: "public/assets/app.js", status: "M" },
|
||||
{ path: "config/app.json", status: "M" },
|
||||
{ path: "scripts/migrate.py", status: "M" },
|
||||
{ path: "src/Model/LegacyUser.php", status: "D" },
|
||||
];
|
||||
|
||||
const diffs = {};
|
||||
const changes = changeDefs.map((c) => {
|
||||
const orig = O[c.path] != null ? O[c.path] : "";
|
||||
const upd = F[c.path] != null ? F[c.path] : "";
|
||||
const d = buildDiff(orig, upd);
|
||||
diffs[c.path] = Object.assign(d, {
|
||||
deleted: c.status === "D",
|
||||
added: c.status === "A",
|
||||
original: orig,
|
||||
updated: upd,
|
||||
});
|
||||
return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === "D" };
|
||||
});
|
||||
|
||||
window.PROJECT = {
|
||||
name: "console",
|
||||
branch: "feat/payments-balance",
|
||||
files: F,
|
||||
originals: O,
|
||||
tree,
|
||||
diffs,
|
||||
changes,
|
||||
};
|
||||
})();
|
||||
272
design_handoff_helder_workbench/design/src/editor.jsx
Normal file
272
design_handoff_helder_workbench/design/src/editor.jsx
Normal file
@@ -0,0 +1,272 @@
|
||||
/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */
|
||||
|
||||
function climbToLine(node) {
|
||||
let el = node && node.nodeType === 3 ? node.parentElement : node;
|
||||
while (el && !(el.dataset && el.dataset.line)) el = el.parentElement;
|
||||
return el || null;
|
||||
}
|
||||
|
||||
function EditorTabs({ tabs, active, onActivate, onClose }) {
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current && ref.current.querySelector(".tab.active");
|
||||
if (el) el.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
}, [active]);
|
||||
return (
|
||||
<div className="tabs" ref={ref}>
|
||||
{tabs.map((t) => {
|
||||
const name = t.path.split("/").pop();
|
||||
return (
|
||||
<div key={t.path}
|
||||
className={"tab" + (active === t.path ? " active" : "")}
|
||||
onClick={() => onActivate(t.path)}
|
||||
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path); } }}
|
||||
title={t.path}>
|
||||
<FileIcon path={t.path} />
|
||||
<span className="tname">{name}</span>
|
||||
{t.changed && <span className="tab-mode">{t.modeLabel}</span>}
|
||||
<span className="tclose" onClick={(e) => { e.stopPropagation(); onClose(t.path); }}>
|
||||
{Icon.close({})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Generic pane: renders an array of line descriptors with selection + caret + context. */
|
||||
function PaneView({ cacheKey, path, lines, lang, showSign, refLine, cursor, selection, setCursor, setSelection, onContext }) {
|
||||
const anchorRef = useRef(null);
|
||||
const html = useMemo(() => lines.map((l) => HL.hlLine(l.text, lang)), [cacheKey]);
|
||||
|
||||
function gutterClick(e, no) {
|
||||
if (no == null) return;
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey && anchorRef.current != null) {
|
||||
const a = anchorRef.current;
|
||||
setSelection({ path, start: Math.min(a, no), end: Math.max(a, no), anchor: a });
|
||||
} else {
|
||||
anchorRef.current = no;
|
||||
setSelection({ path, start: no, end: no, anchor: no });
|
||||
}
|
||||
setCursor({ path, line: no, col: 1 });
|
||||
}
|
||||
function caretCol(sel) {
|
||||
try {
|
||||
const el = climbToLine(sel.focusNode);
|
||||
const code = el.querySelector(".ln-code");
|
||||
const r = document.createRange();
|
||||
r.setStart(code, 0); r.setEnd(sel.focusNode, sel.focusOffset);
|
||||
return r.toString().length + 1;
|
||||
} catch (e) { return 1; }
|
||||
}
|
||||
function onMouseUp() {
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed) {
|
||||
const a = climbToLine(sel.anchorNode), f = climbToLine(sel.focusNode);
|
||||
if (a && f) {
|
||||
const an = +a.dataset.line, fn = +f.dataset.line;
|
||||
const s = Math.min(an, fn), e = Math.max(an, fn);
|
||||
if (s !== e) { setSelection({ path, start: s, end: e, anchor: an }); setCursor({ path, line: fn, col: caretCol(sel) }); return; }
|
||||
}
|
||||
}
|
||||
if (sel && sel.focusNode) {
|
||||
const el = climbToLine(sel.focusNode);
|
||||
if (el) { setCursor({ path, line: +el.dataset.line, col: caretCol(sel) }); setSelection(null); }
|
||||
}
|
||||
}
|
||||
function handleContext(e) {
|
||||
e.preventDefault();
|
||||
const sel = window.getSelection();
|
||||
let info = { path, kind: "editor" };
|
||||
const a = sel && sel.anchorNode && climbToLine(sel.anchorNode);
|
||||
const f = sel && sel.focusNode && climbToLine(sel.focusNode);
|
||||
if (sel && !sel.isCollapsed && a && f && +a.dataset.line !== +f.dataset.line) {
|
||||
const s = Math.min(+a.dataset.line, +f.dataset.line), en = Math.max(+a.dataset.line, +f.dataset.line);
|
||||
info.sel = { start: s, end: en }; info.line = s;
|
||||
} else if (selection && selection.path === path && selection.start !== selection.end) {
|
||||
info.sel = { start: selection.start, end: selection.end }; info.line = selection.start;
|
||||
} else {
|
||||
let no = null;
|
||||
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
|
||||
const el = r && climbToLine(r.startContainer);
|
||||
if (el) no = +el.dataset.line;
|
||||
info.line = no || (cursor && cursor.path === path ? cursor.line : 1);
|
||||
}
|
||||
setCursor({ path, line: info.line, col: 1 });
|
||||
onContext(e, info);
|
||||
}
|
||||
|
||||
const curLine = cursor && cursor.path === path ? cursor.line : -1;
|
||||
const sel = selection && selection.path === path ? selection : null;
|
||||
|
||||
return (
|
||||
<div className={"editor" + (showSign ? " diff" : "")} onMouseUp={onMouseUp} onContextMenu={handleContext}>
|
||||
{lines.map((l, i) => {
|
||||
const no = l.no;
|
||||
const inSel = sel && no != null && no >= sel.start && no <= sel.end;
|
||||
const cls = "ln-row"
|
||||
+ (l.row === "add" ? " add" : l.row === "del" ? " del" : "")
|
||||
+ (l.row === "bar-add" ? " bar-add" : l.row === "bar-del" ? " bar-del" : "")
|
||||
+ (no === curLine && !inSel && !l.row ? " cursor" : "")
|
||||
+ (inSel ? " selrange" : "");
|
||||
return (
|
||||
<div key={i} data-line={no == null ? undefined : no} className={cls}>
|
||||
<span className="ln-gutter" onClick={(e) => gutterClick(e, no)}>{no == null ? "" : no}</span>
|
||||
{showSign && <span className="ln-sign">{l.sign === " " || !l.sign ? "" : l.sign}</span>}
|
||||
<span className="ln-code" dangerouslySetInnerHTML={{ __html: html[i] }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Build the line descriptors for a given mode. */
|
||||
function buildLines(mode, diff, fileText) {
|
||||
if (mode === "original") return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === "del" ? "bar-del" : null })), showSign: false };
|
||||
if (mode === "updated") return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === "add" ? "bar-add" : null })), showSign: false };
|
||||
if (mode === "diff") return { lines: diff.rows.map((r) => ({ no: r.newNo || r.oldNo, text: r.text, sign: r.sign, row: r.sign === "+" ? "add" : r.sign === "-" ? "del" : null })), showSign: true };
|
||||
// plain file
|
||||
const arr = (fileText || "").replace(/\n$/, "").split("\n");
|
||||
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false };
|
||||
}
|
||||
|
||||
const SEGMENTS = [
|
||||
{ id: "original", label: "Original" },
|
||||
{ id: "updated", label: "Updated" },
|
||||
{ id: "diff", label: "Diff" },
|
||||
];
|
||||
|
||||
function Editor({ tabs, active, mode, setMode, onActivate, onClose, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection }) {
|
||||
const tab = tabs.find((t) => t.path === active);
|
||||
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null;
|
||||
const diff = tab ? PROJECT.diffs[tab.path] : null;
|
||||
const lang = tab ? HL.langFor(tab.path) : null;
|
||||
const effMode = change ? mode : "code";
|
||||
|
||||
let built = null;
|
||||
if (tab) {
|
||||
if (change && diff) built = buildLines(effMode, diff, PROJECT.files[tab.path]);
|
||||
else built = buildLines("code", null, PROJECT.files[tab.path]);
|
||||
}
|
||||
|
||||
const statusWord = change ? (change.status === "A" ? "Added" : change.status === "D" ? "Deleted" : "Modified") : "";
|
||||
const activeSeg = splitOpen ? "split" : effMode;
|
||||
const emptyUpdated = effMode === "updated" && built && built.lines.length === 0;
|
||||
const emptyOriginal = effMode === "original" && built && built.lines.length === 0;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EditorTabs tabs={tabs} active={active} onActivate={onActivate} onClose={onClose} />
|
||||
{!tab ? (
|
||||
<div className="empty-ed">
|
||||
<div style={{ opacity: .5 }}>{Icon.file({ width: 30, height: 30 })}</div>
|
||||
<div className="big">No file open</div>
|
||||
<div className="klist">
|
||||
<div><span>Search files & content</span><kbd>⌘ F</kbd></div>
|
||||
<div><span>Copy reference</span><kbd>right-click</kbd></div>
|
||||
<div><span>Pass on to Agent</span><kbd>right-click</kbd></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="editor-wrap">
|
||||
{change && (
|
||||
<div className="diff-bar">
|
||||
<span className={"git-stat " + change.status} style={{ width: "auto" }}>{statusWord}</span>
|
||||
{change.add > 0 && <span className="a">+{change.add}</span>}
|
||||
{change.del > 0 && <span className="d">−{change.del}</span>}
|
||||
<div className="seg">
|
||||
{SEGMENTS.map((s) => (
|
||||
<button key={s.id} className={activeSeg === s.id ? "on" : ""} onClick={() => setMode(s.id)}>{s.label}</button>
|
||||
))}
|
||||
<button className={"split-btn" + (activeSeg === "split" ? " on" : "")} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><rect x="1" y="1.5" width="10" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2"/><line x1="6" y1="1.5" x2="6" y2="10.5" stroke="currentColor" strokeWidth="1.2"/></svg>
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{emptyUpdated ? (
|
||||
<div className="empty-ed"><div className="big" style={{ color: "var(--del)" }}>No updated version</div><div style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--fg-3)" }}>This file was deleted in the change.</div></div>
|
||||
) : emptyOriginal ? (
|
||||
<div className="empty-ed"><div className="big" style={{ color: "var(--add)" }}>No original version</div><div style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--fg-3)" }}>This file is new in the change.</div></div>
|
||||
) : (
|
||||
<PaneView cacheKey={tab.path + ":" + effMode} path={tab.path} lines={built.lines}
|
||||
lang={lang} showSign={built.showSign} cursor={cursor} selection={selection}
|
||||
setCursor={setCursor} setSelection={setSelection} onContext={onContext} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
/* Full-screen side-by-side split view */
|
||||
function SplitView({ path, onClose, onContext }) {
|
||||
const diff = PROJECT.diffs[path];
|
||||
const lang = HL.langFor(path);
|
||||
const leftRef = useRef(null), rightRef = useRef(null);
|
||||
const lock = useRef(false);
|
||||
const change = PROJECT.changes.find((c) => c.path === path);
|
||||
|
||||
const leftHtml = useMemo(() => diff.split.map((r) => r.l ? HL.hlLine(r.l.text, lang) : ""), [path]);
|
||||
const rightHtml = useMemo(() => diff.split.map((r) => r.r ? HL.hlLine(r.r.text, lang) : ""), [path]);
|
||||
|
||||
function sync(from, to) {
|
||||
if (lock.current) return; lock.current = true;
|
||||
to.scrollTop = from.scrollTop; to.scrollLeft = from.scrollLeft;
|
||||
requestAnimationFrame(() => { lock.current = false; });
|
||||
}
|
||||
function ctx(e, side) {
|
||||
e.preventDefault();
|
||||
let no = null;
|
||||
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
|
||||
const el = r && climbToLine(r.startContainer);
|
||||
if (el) no = +el.dataset.line;
|
||||
onContext(e, { path, kind: "editor", line: no || 1 });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="split-overlay">
|
||||
<div className="split-head">
|
||||
<FileIcon path={path} />
|
||||
<span className="sh-name">{path}</span>
|
||||
{change && <span className={"git-stat " + change.status} style={{ width: "auto" }}>{change.status === "A" ? "Added" : change.status === "D" ? "Deleted" : "Modified"}</span>}
|
||||
{change && change.add > 0 && <span className="a" style={{ fontFamily: "var(--mono)", color: "var(--add)" }}>+{change.add}</span>}
|
||||
{change && change.del > 0 && <span className="d" style={{ fontFamily: "var(--mono)", color: "var(--del)" }}>−{change.del}</span>}
|
||||
<button className="split-exit" onClick={onClose}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7 1.5h3.5V5M5 10.5H1.5V7M10.5 1.5L7 5M1.5 10.5L5 7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
Collapse <kbd>Esc</kbd>
|
||||
</button>
|
||||
</div>
|
||||
<div className="split-body">
|
||||
<div className="split-pane left">
|
||||
<div className="split-label">Original <span>before</span></div>
|
||||
<div className="editor" ref={leftRef} onScroll={() => sync(leftRef.current, rightRef.current)} onContextMenu={(e) => ctx(e, "l")}>
|
||||
{diff.split.map((row, i) => (
|
||||
<div key={i} data-line={row.l ? row.l.no : undefined} className={"ln-row" + (row.l && row.l.mark === "del" ? " bar-del" : "") + (!row.l ? " empty" : "")}>
|
||||
<span className="ln-gutter">{row.l ? row.l.no : ""}</span>
|
||||
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.l ? leftHtml[i] : "" }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="split-pane right">
|
||||
<div className="split-label">Updated <span>after</span></div>
|
||||
<div className="editor" ref={rightRef} onScroll={() => sync(rightRef.current, leftRef.current)} onContextMenu={(e) => ctx(e, "r")}>
|
||||
{diff.split.map((row, i) => (
|
||||
<div key={i} data-line={row.r ? row.r.no : undefined} className={"ln-row" + (row.r && row.r.mark === "add" ? " bar-add" : "") + (!row.r ? " empty" : "")}>
|
||||
<span className="ln-gutter">{row.r ? row.r.no : ""}</span>
|
||||
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.r ? rightHtml[i] : "" }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Editor, EditorTabs, PaneView, SplitView, buildLines, climbToLine });
|
||||
79
design_handoff_helder_workbench/design/src/highlight.js
Normal file
79
design_handoff_helder_workbench/design/src/highlight.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/* Syntax highlighting (Prism) + file-type icon metadata. */
|
||||
(function () {
|
||||
const EXT_LANG = {
|
||||
php: "php", js: "javascript", mjs: "javascript", cjs: "javascript",
|
||||
jsx: "jsx", ts: "typescript", tsx: "tsx", py: "python",
|
||||
html: "markup", xml: "markup", svg: "markup", vue: "markup",
|
||||
css: "css", scss: "css", json: "json", md: "markdown",
|
||||
sh: "bash", bash: "bash", yml: "yaml", yaml: "yaml", env: "bash",
|
||||
};
|
||||
|
||||
function ext(path) {
|
||||
const base = path.split("/").pop() || "";
|
||||
if (base === ".env" || base.startsWith(".env")) return "env";
|
||||
const i = base.lastIndexOf(".");
|
||||
return i >= 0 ? base.slice(i + 1).toLowerCase() : "";
|
||||
}
|
||||
|
||||
function langFor(path) { return EXT_LANG[ext(path)] || null; }
|
||||
|
||||
function langLabel(path) {
|
||||
const e = ext(path);
|
||||
const map = {
|
||||
php: "PHP", js: "JavaScript", mjs: "JavaScript", ts: "TypeScript",
|
||||
tsx: "TypeScript", jsx: "JavaScript", py: "Python", html: "HTML",
|
||||
css: "CSS", json: "JSON", md: "Markdown", sh: "Shell", env: "Dotenv",
|
||||
yml: "YAML", yaml: "YAML",
|
||||
};
|
||||
return map[e] || (e ? e.toUpperCase() : "Plain Text");
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||
}
|
||||
|
||||
// highlight a single line independently (keeps line numbering robust)
|
||||
function hlLine(line, lang) {
|
||||
if (line === "") return " ";
|
||||
try {
|
||||
const grammar = lang && window.Prism && Prism.languages[lang];
|
||||
if (grammar) return Prism.highlight(line, grammar, lang);
|
||||
} catch (e) { /* fall through */ }
|
||||
return escapeHtml(line);
|
||||
}
|
||||
|
||||
// ---- file-type icon: colored monogram chip --------------------------
|
||||
const ICONS = {
|
||||
php: { c: "#a78bdb", t: "php" },
|
||||
js: { c: "#e6c860", t: "js" },
|
||||
mjs: { c: "#e6c860", t: "js" },
|
||||
ts: { c: "#5a9bd6", t: "ts" },
|
||||
tsx: { c: "#5a9bd6", t: "ts" },
|
||||
jsx: { c: "#5a9bd6", t: "jsx" },
|
||||
py: { c: "#5fa8d6", t: "py" },
|
||||
html: { c: "#e08b6a", t: "<>" },
|
||||
css: { c: "#5a9bd6", t: "{}" },
|
||||
scss: { c: "#d6699e", t: "{}" },
|
||||
json: { c: "#d8a85c", t: "{}" },
|
||||
md: { c: "#9aa0a8", t: "md" },
|
||||
env: { c: "#7fc6a0", t: "$" },
|
||||
sh: { c: "#7fc6a0", t: "$" },
|
||||
yml: { c: "#cf7a6a", t: "yml" },
|
||||
yaml: { c: "#cf7a6a", t: "yml" },
|
||||
lock: { c: "#8a8f98", t: "lk" },
|
||||
};
|
||||
const NAME_ICONS = {
|
||||
"composer.json": { c: "#a78bdb", t: "co" },
|
||||
"package.json": { c: "#cf7a6a", t: "pk" },
|
||||
"README.md": { c: "#5a9bd6", t: "md" },
|
||||
".env": { c: "#7fc6a0", t: "$" },
|
||||
};
|
||||
|
||||
function iconFor(path) {
|
||||
const base = path.split("/").pop() || "";
|
||||
if (NAME_ICONS[base]) return NAME_ICONS[base];
|
||||
return ICONS[ext(path)] || { c: "#7d838c", t: base.slice(0, 2) || "·" };
|
||||
}
|
||||
|
||||
window.HL = { ext, langFor, langLabel, hlLine, iconFor, escapeHtml };
|
||||
})();
|
||||
226
design_handoff_helder_workbench/design/src/overlays.jsx
Normal file
226
design_handoff_helder_workbench/design/src/overlays.jsx
Normal file
@@ -0,0 +1,226 @@
|
||||
/* Overlays: command palette (fuzzy file finder), content search, context menu, toast */
|
||||
|
||||
function fuzzy(q, str) {
|
||||
q = q.toLowerCase(); const s = str.toLowerCase();
|
||||
let i = 0; const idx = [];
|
||||
for (let j = 0; j < s.length && i < q.length; j++) {
|
||||
if (s[j] === q[i]) { idx.push(j); i++; }
|
||||
}
|
||||
return i === q.length ? idx : null;
|
||||
}
|
||||
|
||||
function Highlight({ text, idx }) {
|
||||
if (!idx || !idx.length) return <span>{text}</span>;
|
||||
const set = new Set(idx);
|
||||
return <span>{text.split("").map((ch, i) => set.has(i) ? <b key={i}>{ch}</b> : <React.Fragment key={i}>{ch}</React.Fragment>)}</span>;
|
||||
}
|
||||
|
||||
function SearchModal({ onOpen, onOpenAt, onClose, changeSet }) {
|
||||
const [q, setQ] = useState("");
|
||||
const [sel, setSel] = useState(0);
|
||||
const inputRef = useRef(null);
|
||||
const leftRef = useRef(null);
|
||||
const allPaths = useMemo(() => Object.keys(PROJECT.files), []);
|
||||
|
||||
useEffect(() => { inputRef.current && inputRef.current.focus(); }, []);
|
||||
|
||||
// content hits (left)
|
||||
const content = useMemo(() => {
|
||||
const term = q.trim();
|
||||
if (term.length < 2) return [];
|
||||
const low = term.toLowerCase();
|
||||
const groups = [];
|
||||
for (const [path, src] of Object.entries(PROJECT.files)) {
|
||||
const lines = src.split("\n");
|
||||
const hits = [];
|
||||
lines.forEach((ln, i) => {
|
||||
const ix = ln.toLowerCase().indexOf(low);
|
||||
if (ix >= 0) hits.push({ no: i + 1, ln, ix });
|
||||
});
|
||||
if (hits.length) groups.push({ path, hits });
|
||||
}
|
||||
return groups;
|
||||
}, [q]);
|
||||
|
||||
// file-name matches (right)
|
||||
const files = useMemo(() => {
|
||||
const term = q.trim();
|
||||
if (!term) return [];
|
||||
const out = [];
|
||||
for (const p of allPaths) {
|
||||
const name = p.split("/").pop();
|
||||
const ni = fuzzy(term, name);
|
||||
if (ni) { out.push({ path: p, idx: ni, rank: 0, pos: ni[0] }); continue; }
|
||||
const pi = fuzzy(term, p);
|
||||
if (pi) out.push({ path: p, idx: null, rank: 1, pos: pi[0] });
|
||||
}
|
||||
out.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.path.length - b.path.length);
|
||||
return out;
|
||||
}, [q]);
|
||||
|
||||
// flat list of content hits for keyboard nav
|
||||
const flat = useMemo(() => {
|
||||
const arr = [];
|
||||
content.forEach((g) => g.hits.forEach((h) => arr.push({ path: g.path, no: h.no })));
|
||||
return arr;
|
||||
}, [content]);
|
||||
|
||||
const totalHits = flat.length;
|
||||
useEffect(() => { setSel(0); }, [q]);
|
||||
useEffect(() => {
|
||||
const el = leftRef.current && leftRef.current.querySelector(".sr-line.sel");
|
||||
if (el) el.scrollIntoView({ block: "nearest" });
|
||||
}, [sel]);
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setSel((s) => Math.min(s + 1, flat.length - 1)); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)); }
|
||||
else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose(); }
|
||||
else if (files[0]) { onOpen(files[0].path); onClose(); }
|
||||
} else if (e.key === "Escape") { e.preventDefault(); onClose(); }
|
||||
}
|
||||
|
||||
function renderLine(ln, ix, len) {
|
||||
const pre = ln.slice(0, ix), mid = ln.slice(ix, ix + len), post = ln.slice(ix + len);
|
||||
return <span className="tx">{pre}<mark>{mid}</mark>{post}</span>;
|
||||
}
|
||||
|
||||
const term = q.trim();
|
||||
let flatIx = -1;
|
||||
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="search-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="pi">
|
||||
{Icon.search({ style: { color: "var(--fg-3)" } })}
|
||||
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey}
|
||||
placeholder="Search content and file names…" spellCheck={false} />
|
||||
<span className="mode-chip">{totalHits} hit{totalHits === 1 ? "" : "s"} · {files.length} file{files.length === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
<div className="search-cols">
|
||||
<div className="sc-left" ref={leftRef}>
|
||||
<div className="sc-head">Content {totalHits > 0 && <span className="sc-ct">{totalHits}</span>}</div>
|
||||
{term.length < 2 && <div className="pempty">Type at least 2 characters</div>}
|
||||
{term.length >= 2 && content.length === 0 && <div className="pempty">No content matches</div>}
|
||||
{content.map((g) => (
|
||||
<React.Fragment key={g.path}>
|
||||
<div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}>
|
||||
<FileIcon path={g.path} />
|
||||
<span className="srf-name">{g.path}</span>
|
||||
<span className="cnt">{g.hits.length}</span>
|
||||
</div>
|
||||
{g.hits.slice(0, 12).map((h) => {
|
||||
flatIx++;
|
||||
const me = flatIx;
|
||||
return (
|
||||
<div key={h.no} className={"sr-line" + (me === sel ? " sel" : "")}
|
||||
onMouseEnter={() => setSel(me)}
|
||||
onClick={() => { onOpenAt(g.path, h.no); onClose(); }}>
|
||||
<span className="no">{h.no}</span>
|
||||
{renderLine(h.ln, h.ix, term.length)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="sc-right">
|
||||
<div className="sc-head">Files {files.length > 0 && <span className="sc-ct">{files.length}</span>}</div>
|
||||
{!term && <div className="pempty sm">Start typing…</div>}
|
||||
{term && files.length === 0 && <div className="pempty sm">No file names match</div>}
|
||||
{files.slice(0, 40).map((r) => {
|
||||
const name = r.path.split("/").pop();
|
||||
const dir = r.path.split("/").slice(0, -1).join("/");
|
||||
return (
|
||||
<div key={r.path} className="fres" onClick={() => { onOpen(r.path); onClose(); }} title={r.path}>
|
||||
<FileIcon path={r.path} />
|
||||
<div className="fres-txt">
|
||||
<span className="fn"><Highlight text={name} idx={r.idx} /></span>
|
||||
{dir && <span className="fd">{dir}/</span>}
|
||||
</div>
|
||||
{changeSet.has(r.path) && <span className="tree-badge M" style={{ fontFamily: "var(--mono)", fontSize: 10 }}>•</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenu({ menu, onClose }) {
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
|
||||
const k = (e) => { if (e.key === "Escape") onClose(); };
|
||||
document.addEventListener("mousedown", h);
|
||||
document.addEventListener("keydown", k);
|
||||
return () => { document.removeEventListener("mousedown", h); document.removeEventListener("keydown", k); };
|
||||
}, []);
|
||||
if (!menu) return null;
|
||||
const x = Math.min(menu.x, window.innerWidth - 270);
|
||||
const y = Math.min(menu.y, window.innerHeight - (menu.items.length * 34 + 60));
|
||||
return (
|
||||
<div className="ctx" ref={ref} style={{ left: x, top: y }}>
|
||||
{menu.note && <div className="ctx-note">{menu.note}</div>}
|
||||
{menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : (
|
||||
<div key={i} className={"ctx-item" + (it.primary ? " primary" : "")}
|
||||
onClick={() => { it.onClick(); onClose(); }}>
|
||||
<span className="ic">{it.icon}</span>
|
||||
<span>{it.label}</span>
|
||||
{it.kbd && <span className="kc">{it.kbd}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toasts({ toasts }) {
|
||||
return (
|
||||
<div className="toast-wrap">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className="toast">
|
||||
{Icon.copy({ style: { color: "var(--accent)" } })}
|
||||
<span className="tt">{t.title}</span>
|
||||
{t.ref && <span className="tref">{t.ref}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PassPopup({ x, y, refStr, onConfirm, onCancel }) {
|
||||
const [text, setText] = useState("");
|
||||
const inputRef = useRef(null);
|
||||
const boxRef = useRef(null);
|
||||
useEffect(() => { inputRef.current && inputRef.current.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const h = (e) => { if (boxRef.current && !boxRef.current.contains(e.target)) onCancel(); };
|
||||
const k = (e) => { if (e.key === "Escape") { e.preventDefault(); onCancel(); } };
|
||||
document.addEventListener("mousedown", h);
|
||||
document.addEventListener("keydown", k, true);
|
||||
return () => { document.removeEventListener("mousedown", h); document.removeEventListener("keydown", k, true); };
|
||||
}, []);
|
||||
const left = Math.min(x, window.innerWidth - 360);
|
||||
const top = Math.min(y + 6, window.innerHeight - 150);
|
||||
const preview = (text.trim() ? text.trim() + " " : "") + refStr;
|
||||
return (
|
||||
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
|
||||
<div className="pass-head">{Icon.spark({})}<span>Pass on to Agent</span><span className="pass-esc">esc</span></div>
|
||||
<input ref={inputRef} className="pass-input" value={text} spellCheck={false}
|
||||
placeholder="Add a note (optional)…"
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); onConfirm(text); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); onCancel(); }
|
||||
}} />
|
||||
<div className="pass-preview"><span className="pp-lbl">inserts</span><code>{preview}</code></div>
|
||||
<div className="pass-foot"><kbd>↵</kbd> insert into agent · <kbd>esc</kbd> cancel</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { SearchModal, ContextMenu, Toasts, PassPopup, fuzzy });
|
||||
231
design_handoff_helder_workbench/design/src/terminals.jsx
Normal file
231
design_handoff_helder_workbench/design/src/terminals.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
/* Terminals: a generic shell that boots an (original-styled) AI agent session via `claude`. */
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let _lid = 0;
|
||||
const lid = () => ++_lid;
|
||||
|
||||
function Terminal({ kind, seed }) {
|
||||
const [lines, setLines] = useState(seed.lines);
|
||||
const [mode, setMode] = useState(seed.mode);
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const bodyRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const hist = useRef([]);
|
||||
const histIx = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const el = bodyRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines, busy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== "agent") return;
|
||||
const h = (e) => {
|
||||
if (mode !== "agent") { pushMany(bootAgent()); setMode("agent"); }
|
||||
// bracketed-paste semantics: append as a new, UNSUBMITTED line; leave caret on a fresh line
|
||||
setInput((prev) => {
|
||||
const base = prev.replace(/\n+$/, "");
|
||||
return (base ? base + "\n" : "") + e.detail + "\n";
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
const el = inputRef.current;
|
||||
if (el) { el.focus(); const v = el.value.length; el.setSelectionRange(v, v); }
|
||||
});
|
||||
};
|
||||
window.addEventListener("agentPaste", h);
|
||||
return () => window.removeEventListener("agentPaste", h);
|
||||
}, [kind, mode]);
|
||||
|
||||
const push = (line) => setLines((p) => [...p, { id: lid(), ...line }]);
|
||||
const pushMany = (arr) => setLines((p) => [...p, ...arr.map((l) => ({ id: lid(), ...l }))]);
|
||||
|
||||
async function runAgent(prompt) {
|
||||
setBusy(true);
|
||||
push({ kind: "agent-think", html: `<span class="ag">●</span> <span class="dim">thinking…</span>` });
|
||||
await wait(520);
|
||||
setLines((p) => p.slice(0, -1)); // drop the thinking line
|
||||
push({ kind: "t", cls: "", html: `<span class="ag">●</span> I'll take a look at the relevant files first.` });
|
||||
await wait(380);
|
||||
push({ kind: "t", cls: "dim", html: ` <span class="tool">read</span> <span class="fp">src/Http/Controller/UserController.php</span>` });
|
||||
await wait(300);
|
||||
push({ kind: "t", cls: "dim", html: ` <span class="tool">grep</span> <span class="dim">"balanceFor" → 2 matches</span>` });
|
||||
await wait(420);
|
||||
push({ kind: "t", cls: "", html: `<span class="ag">●</span> Adding the field and a fillable allow-list. Editing now.` });
|
||||
await wait(360);
|
||||
push({
|
||||
kind: "card", ch: `edit · src/Http/Controller/UserController.php`,
|
||||
rows: [
|
||||
{ cls: "del", t: "- 'plan' => $user->plan," },
|
||||
{ cls: "add", t: "+ 'plan' => $user->plan->value," },
|
||||
{ cls: "add", t: "+ 'currency' => $user->currency," },
|
||||
],
|
||||
});
|
||||
await wait(450);
|
||||
push({ kind: "t", cls: "ok", html: `<span class="ag">●</span> <span class="ok">Done.</span> Updated <span class="fp">UserController.php</span>. Run tests with <span class="fp">composer test</span>.` });
|
||||
setBusy(false);
|
||||
requestAnimationFrame(() => inputRef.current && inputRef.current.focus());
|
||||
}
|
||||
|
||||
function shell(cmd) {
|
||||
const [c, ...rest] = cmd.split(/\s+/);
|
||||
const arg = rest.join(" ");
|
||||
switch (c) {
|
||||
case "": return;
|
||||
case "claude":
|
||||
pushMany(bootAgent());
|
||||
setMode("agent");
|
||||
return;
|
||||
case "clear": setLines([]); return;
|
||||
case "help":
|
||||
pushMany([
|
||||
{ kind: "t", cls: "dim", html: "commands: <span style='color:var(--fg-1)'>claude</span> ls pwd cat <file> git status git diff echo clear" },
|
||||
]); return;
|
||||
case "pwd": push({ kind: "t", html: "/Users/dev/console" }); return;
|
||||
case "ls":
|
||||
push({ kind: "t", html: "<span class='fp'>config</span> <span class='fp'>public</span> <span class='fp'>scripts</span> <span class='fp'>src</span> <span class='fp'>templates</span> composer.json package.json README.md" });
|
||||
return;
|
||||
case "echo": push({ kind: "t", html: HL.escapeHtml(arg) }); return;
|
||||
case "git":
|
||||
if (rest[0] === "status") {
|
||||
pushMany([
|
||||
{ kind: "t", cls: "dim", html: `On branch <span style='color:var(--fg-1)'>${PROJECT.branch}</span>` },
|
||||
{ kind: "t", cls: "dim", html: "Changes to be committed:" },
|
||||
...PROJECT.changes.map((ch) => ({
|
||||
kind: "t",
|
||||
html: ` <span style="color:var(--${ch.status === 'A' ? 'add' : ch.status === 'D' ? 'del' : 'mod'})">${ch.status === 'A' ? 'new file' : ch.status === 'D' ? 'deleted ' : 'modified'}</span> <span class="fp">${ch.path}</span>`,
|
||||
})),
|
||||
]);
|
||||
} else if (rest[0] === "diff") {
|
||||
pushMany([
|
||||
{ kind: "t", cls: "dim", html: "diff --git a/public/assets/app.js b/public/assets/app.js" },
|
||||
{ kind: "t", cls: "err", html: "- const res = await fetch('/api/session');" },
|
||||
{ kind: "t", cls: "ok", html: "+ const res = await fetch('/api/session', { credentials: 'include' });" },
|
||||
]);
|
||||
} else push({ kind: "t", cls: "dim", html: `git: '${rest[0] || ""}' is not handled in this demo` });
|
||||
return;
|
||||
case "cat": {
|
||||
const f = PROJECT.files[arg] || Object.entries(PROJECT.files).find(([p]) => p.endsWith(arg))?.[1];
|
||||
if (!f) { push({ kind: "t", cls: "err", html: `cat: ${HL.escapeHtml(arg)}: No such file` }); return; }
|
||||
pushMany(f.replace(/\n$/, "").split("\n").slice(0, 24).map((l) => ({ kind: "t", cls: "dim", html: HL.escapeHtml(l) || " " })));
|
||||
return;
|
||||
}
|
||||
default: push({ kind: "t", cls: "err", html: `${HL.escapeHtml(c)}: command not found` });
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const cmd = input.trim();
|
||||
if (busy) return;
|
||||
if (cmd) { hist.current.unshift(cmd); }
|
||||
histIx.current = -1;
|
||||
if (mode === "agent") {
|
||||
if (cmd === "/exit" || cmd === "exit") {
|
||||
push({ kind: "t", cls: "dim", html: "<span class='ag'>●</span> Session ended." });
|
||||
setMode("shell"); setInput(""); return;
|
||||
}
|
||||
if (cmd === "/clear" || cmd === "clear") { setLines([]); setInput(""); return; }
|
||||
push({ kind: "t", html: `<span class="ip ag">></span> ${HL.escapeHtml(cmd).replace(/\n/g, "<br> ") || " "}` });
|
||||
setInput("");
|
||||
if (cmd) runAgent(cmd);
|
||||
return;
|
||||
}
|
||||
push({ kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> ${HL.escapeHtml(cmd) || " "}` });
|
||||
setInput("");
|
||||
shell(cmd);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); return; }
|
||||
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !input.includes("\n")) {
|
||||
e.preventDefault();
|
||||
if (e.key === "ArrowUp") { if (hist.current.length) { histIx.current = Math.min(histIx.current + 1, hist.current.length - 1); setInput(hist.current[histIx.current]); } }
|
||||
else { if (histIx.current > 0) { histIx.current--; setInput(hist.current[histIx.current]); } else { histIx.current = -1; setInput(""); } }
|
||||
}
|
||||
}
|
||||
|
||||
const live = mode === "agent";
|
||||
return (
|
||||
<div className="term-pane" style={{ flex: 1, minHeight: 0 }} onMouseDown={() => inputRef.current && inputRef.current.focus()}>
|
||||
<div className="term-head">
|
||||
<span className={"dot" + (live ? " live" : "")}></span>
|
||||
<span className="lbl">{live ? "claude" : kind === "agent" ? "claude" : "zsh"}</span>
|
||||
<span className="tag">{live ? "agent session" : "— bash · ~/console"}</span>
|
||||
</div>
|
||||
<div className="term-body" ref={bodyRef}>
|
||||
{lines.map((l) => {
|
||||
if (l.kind === "card") {
|
||||
return (
|
||||
<div key={l.id} className="term-card">
|
||||
<div className="ch">{Icon.diff({ width: 11, height: 11 })}<span>{l.ch}</span></div>
|
||||
{l.rows.map((r, i) => (<div key={i} className={r.cls}>{r.t}</div>))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.kind === "welcome") {
|
||||
return (
|
||||
<div key={l.id} className="term-card" style={{ borderColor: "var(--accent-line)" }}>
|
||||
<div style={{ color: "var(--accent)", fontWeight: 600 }}>◇ agent session</div>
|
||||
<div className="dim" style={{ color: "var(--fg-2)", marginTop: 3 }}>{l.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div key={l.id} className={"tline " + (l.cls || "")} dangerouslySetInnerHTML={{ __html: l.html }} />;
|
||||
})}
|
||||
{busy && <div className="tline dim"><span className="ag" style={{ color: "#c98bdb" }}>●</span> working<span className="cursor-blink" /></div>}
|
||||
{!busy && (
|
||||
<div className="term-input">
|
||||
<span className={"ip" + (live ? " ag" : "")}>{live ? ">" : <span className="pfx">console <span style={{ color: "var(--fg-3)" }}>%</span></span>}</span>
|
||||
<textarea ref={inputRef} className="term-ta" value={input} spellCheck={false} autoComplete="off"
|
||||
rows={Math.min(8, Math.max(1, input.split("\n").length))}
|
||||
placeholder={live ? "Ask the agent to change something…" : kind === "agent" ? "type 'claude' to start a session" : ""}
|
||||
onChange={(e) => setInput(e.target.value)} onKeyDown={onKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function bootAgent() {
|
||||
return [
|
||||
{ kind: "welcome", text: "model: opus · cwd: ~/console · branch: " + PROJECT.branch + " · type /exit to leave" },
|
||||
{ kind: "t", cls: "dim", html: " I can read, search and edit files in this project. Describe a change to get started." },
|
||||
];
|
||||
}
|
||||
|
||||
function agentSeed() {
|
||||
return {
|
||||
mode: "agent",
|
||||
lines: [
|
||||
{ id: lid(), kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> claude` },
|
||||
{ id: lid(), kind: "welcome", text: "model: opus · cwd: ~/console · branch: " + PROJECT.branch + " · type /exit to leave" },
|
||||
{ id: lid(), kind: "t", html: `<span class="ip ag">></span> add currency + name to the user payload and gate the fillable fields` },
|
||||
{ id: lid(), kind: "t", html: `<span class="ag">●</span> Read <span class="fp">UserController.php</span>, edited the response array and <span class="fp">onlyFillable()</span>.` },
|
||||
{
|
||||
id: lid(), kind: "card", ch: "edit · src/Http/Controller/UserController.php",
|
||||
rows: [
|
||||
{ cls: "del", t: "- 'plan' => $user->plan," },
|
||||
{ cls: "add", t: "+ 'plan' => $user->plan->value," },
|
||||
{ cls: "add", t: "+ 'name' => $user->name," },
|
||||
],
|
||||
},
|
||||
{ id: lid(), kind: "t", cls: "ok", html: `<span class="ag">●</span> <span class="ok">Done</span> — updated <span class="fp">UserController.php</span>. Anything else?` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function shellSeed() {
|
||||
return {
|
||||
mode: "shell",
|
||||
lines: [
|
||||
{ id: lid(), kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> git status -s` },
|
||||
...PROJECT.changes.map((c) => ({
|
||||
id: lid(), kind: "t",
|
||||
html: `<span style="color:var(--${c.status === 'A' ? 'add' : c.status === 'D' ? 'del' : 'mod'})">${c.status} </span> <span class="fp">${c.path}</span>`,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Object.assign(window, { Terminal, agentSeed, shellSeed });
|
||||
372
design_handoff_helder_workbench/design/styles.css
Normal file
372
design_handoff_helder_workbench/design/styles.css
Normal file
@@ -0,0 +1,372 @@
|
||||
/* ============ Agentic Coding Panel — dark, charcoal-neutral ============ */
|
||||
:root {
|
||||
--bg-0:#16171a; /* editor surface (deepest) */
|
||||
--bg-1:#1a1c1f; /* terminals */
|
||||
--bg-2:#1f2226; /* sidebars */
|
||||
--bg-3:#23262b; /* headers / tabs strip */
|
||||
--hover:#2a2e34;
|
||||
--active:#313742;
|
||||
--sel:#2b323d;
|
||||
--border:#2a2d33;
|
||||
--border-2:#34383f;
|
||||
--fg-0:#e6e8ea;
|
||||
--fg-1:#b4bac2;
|
||||
--fg-2:#838a94;
|
||||
--fg-3:#5d636c;
|
||||
--accent:#4d8dff;
|
||||
--accent-soft:rgba(77,141,255,0.16);
|
||||
--accent-line:rgba(77,141,255,0.55);
|
||||
--add:#5cbd6b;
|
||||
--del:#e0696a;
|
||||
--mod:#d8a85c;
|
||||
--ren:#5aa6d6;
|
||||
--add-bg:rgba(92,189,107,0.10);
|
||||
--del-bg:rgba(224,105,106,0.10);
|
||||
/* syntax */
|
||||
--t-key:#c98bdb;
|
||||
--t-str:#94c980;
|
||||
--t-num:#e0a06a;
|
||||
--t-fn:#6aa6f0;
|
||||
--t-com:#5f656e;
|
||||
--t-tag:#7fc6a0;
|
||||
--t-attr:#d8b15c;
|
||||
--t-punc:#9aa0a8;
|
||||
--t-var:#e6e8ea;
|
||||
--t-const:#e08b6a;
|
||||
--t-prop:#6ec0c0;
|
||||
--ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
}
|
||||
|
||||
* { box-sizing:border-box; }
|
||||
html,body { margin:0; height:100%; }
|
||||
body {
|
||||
background:var(--bg-0); color:var(--fg-0);
|
||||
font-family:var(--ui); font-size:13px;
|
||||
overflow:hidden; -webkit-font-smoothing:antialiased;
|
||||
}
|
||||
#root { height:100vh; }
|
||||
::selection { background:rgba(77,141,255,0.32); }
|
||||
|
||||
/* scrollbars */
|
||||
::-webkit-scrollbar { width:11px; height:11px; }
|
||||
::-webkit-scrollbar-thumb { background:#393e46; border-radius:6px; border:3px solid transparent; background-clip:content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background:#4a505a; background-clip:content-box; }
|
||||
::-webkit-scrollbar-corner { background:transparent; }
|
||||
|
||||
/* ============ shell ============ */
|
||||
.app { display:flex; flex-direction:column; height:100vh; }
|
||||
|
||||
.titlebar {
|
||||
height:36px; flex:0 0 36px; display:flex; align-items:center;
|
||||
background:var(--bg-3); border-bottom:1px solid var(--border);
|
||||
padding:0 12px; gap:14px; user-select:none;
|
||||
}
|
||||
.traffic { display:flex; gap:8px; }
|
||||
.traffic i { width:12px; height:12px; border-radius:50%; display:block; }
|
||||
.traffic .r{background:#e0696a;} .traffic .y{background:#d8a85c;} .traffic .g{background:#5cbd6b;}
|
||||
.tb-title { font-size:12px; color:var(--fg-1); display:flex; align-items:center; gap:7px; }
|
||||
.tb-title b { color:var(--fg-0); font-weight:600; }
|
||||
.tb-crumb { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); }
|
||||
.tb-crumb .seg{color:var(--fg-2);}
|
||||
.tb-spacer { flex:1; }
|
||||
.tb-actions { display:flex; gap:6px; align-items:center; }
|
||||
.tb-btn {
|
||||
font-size:11.5px; color:var(--fg-2); background:transparent; border:1px solid transparent;
|
||||
border-radius:6px; padding:4px 9px; cursor:pointer; display:flex; align-items:center; gap:6px;
|
||||
}
|
||||
.tb-btn:hover { background:var(--hover); color:var(--fg-0); }
|
||||
.tb-btn kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
|
||||
|
||||
.workbench { flex:1; display:flex; min-height:0; }
|
||||
|
||||
.col { display:flex; flex-direction:column; height:100%; min-width:0; background:var(--bg-2); }
|
||||
.col.editor-col { flex:1; background:var(--bg-0); min-width:240px; }
|
||||
.col.right-col { background:var(--bg-1); }
|
||||
|
||||
.splitter { flex:0 0 5px; cursor:col-resize; background:transparent; position:relative; z-index:5; }
|
||||
.splitter::after { content:""; position:absolute; inset:0 2px; background:var(--border); transition:background .12s; }
|
||||
.splitter:hover::after, .splitter.drag::after { background:var(--accent-line); }
|
||||
.splitter.h { cursor:row-resize; flex:0 0 5px; width:100%; }
|
||||
|
||||
/* panel header */
|
||||
.phead {
|
||||
height:30px; flex:0 0 30px; display:flex; align-items:center; gap:8px;
|
||||
padding:0 10px 0 12px; font-size:10.5px; letter-spacing:.09em; text-transform:uppercase;
|
||||
color:var(--fg-2); border-bottom:1px solid var(--border); user-select:none;
|
||||
}
|
||||
.phead .ct { margin-left:auto; font-size:10px; color:var(--fg-3); letter-spacing:.02em; text-transform:none;
|
||||
background:var(--bg-3); border:1px solid var(--border); border-radius:9px; padding:0 7px; line-height:16px; }
|
||||
.phead .ico-btn { color:var(--fg-3); cursor:pointer; padding:2px; border-radius:4px; display:flex; }
|
||||
.phead .ico-btn:hover { background:var(--hover); color:var(--fg-1); }
|
||||
|
||||
/* ============ git panel ============ */
|
||||
.commit-box { padding:9px 10px; border-bottom:1px solid var(--border); display:flex; gap:7px; align-items:flex-start; }
|
||||
.commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:12px; line-height:16px; padding:7px 9px; outline:none; min-height:32px; }
|
||||
.commit-input:focus { border-color:var(--accent-line); }
|
||||
.commit-input::placeholder { color:var(--fg-3); }
|
||||
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#0c1320; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; }
|
||||
.commit-btn:hover:not(:disabled) { background:#5d97ff; }
|
||||
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
|
||||
.git-body { overflow:auto; flex:1; padding:4px 0 10px; }
|
||||
.git-group { padding:8px 12px 3px; font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:6px; }
|
||||
.git-group .gc { color:var(--fg-3); }
|
||||
.git-group .grp-act { margin-left:auto; display:flex; opacity:0; background:transparent; border:0; color:var(--fg-2); padding:2px; border-radius:4px; cursor:pointer; }
|
||||
.git-group:hover .grp-act { opacity:1; }
|
||||
.git-group .grp-act:hover { background:var(--hover); color:var(--fg-0); }
|
||||
.git-empty { display:flex; flex-direction:column; align-items:center; gap:9px; padding:30px 16px; color:var(--fg-3); font-size:12px; text-align:center; }
|
||||
.git-empty svg { color:var(--add); opacity:.7; }
|
||||
.git-none { padding:5px 14px 9px; font-size:11.5px; color:var(--fg-3); }
|
||||
.git-none .key { font-family:var(--mono); font-size:11px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
|
||||
.git-divider { height:1px; background:var(--border); margin:8px 12px 2px; }
|
||||
.git-row {
|
||||
display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative;
|
||||
}
|
||||
.git-row:hover { background:var(--hover); }
|
||||
.git-row.active { background:var(--sel); }
|
||||
.git-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
|
||||
.git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; }
|
||||
.git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);}
|
||||
.git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.git-row.active .git-name { color:var(--fg-0); }
|
||||
.git-name.del { text-decoration:line-through; color:var(--fg-3); }
|
||||
.git-dir { color:var(--fg-3); font-size:11px; margin-left:auto; padding-left:8px; white-space:nowrap; max-width:42%; overflow:hidden; text-overflow:ellipsis; direction:rtl; }
|
||||
.git-act { flex:0 0 auto; display:none; align-items:center; justify-content:center; width:20px; height:20px; padding:0; background:transparent; border:0; border-radius:5px; color:var(--fg-2); cursor:pointer; margin-left:4px; }
|
||||
.git-row:hover .git-act { display:flex; }
|
||||
.git-act:hover { background:var(--active); color:var(--fg-0); }
|
||||
.git-delta { font-family:var(--mono); font-size:10.5px; display:flex; gap:6px; flex:0 0 auto; }
|
||||
.git-delta .a{color:var(--add);} .git-delta .d{color:var(--del);}
|
||||
.git-foot { border-top:1px solid var(--border); padding:8px 12px; display:flex; align-items:center; gap:8px; font-size:11px; color:var(--fg-2); }
|
||||
.branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-1); }
|
||||
.branch-chip b { font-weight:600; color:var(--fg-0); }
|
||||
|
||||
/* ============ file tree ============ */
|
||||
.tree-body { overflow:auto; flex:1; padding:4px 0 14px; }
|
||||
.tree-row { display:flex; align-items:center; gap:6px; padding:2px 10px 2px 0; cursor:pointer; white-space:nowrap; position:relative; height:23px; }
|
||||
.tree-row:hover { background:var(--hover); }
|
||||
.tree-row.active { background:var(--sel); }
|
||||
.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
|
||||
.tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; }
|
||||
.tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; }
|
||||
.tree-row.active .tree-label { color:var(--fg-0); }
|
||||
.tree-row.folder .tree-label { color:var(--fg-1); }
|
||||
.tree-badge { margin-left:auto; font-family:var(--mono); font-size:10px; font-weight:600; padding-left:8px; }
|
||||
.tree-badge.M{color:var(--mod);} .tree-badge.A{color:var(--add);} .tree-badge.D{color:var(--del);}
|
||||
|
||||
/* file type monogram icon */
|
||||
.ficon { width:15px; height:15px; flex:0 0 15px; border-radius:3.5px; display:inline-flex; align-items:center; justify-content:center;
|
||||
font-family:var(--mono); font-size:7.5px; font-weight:700; color:#11131600; position:relative; }
|
||||
.ficon span { color:#0c0d0f; font-size:7.5px; line-height:1; letter-spacing:-.3px; }
|
||||
.folder-ic { width:15px; height:15px; flex:0 0 15px; display:inline-flex; align-items:center; justify-content:center; color:var(--fg-2); }
|
||||
|
||||
/* ============ editor ============ */
|
||||
.tabs { height:35px; flex:0 0 35px; display:flex; align-items:stretch; background:var(--bg-3); border-bottom:1px solid var(--border); overflow-x:auto; overflow-y:hidden; }
|
||||
.tabs::-webkit-scrollbar { height:0; }
|
||||
.tab {
|
||||
display:flex; align-items:center; gap:7px; padding:0 9px 0 13px; cursor:pointer;
|
||||
border-right:1px solid var(--border); color:var(--fg-2); font-size:12.5px; white-space:nowrap;
|
||||
background:var(--bg-3); position:relative; max-width:230px;
|
||||
}
|
||||
.tab:hover { background:#272b31; }
|
||||
.tab.active { background:var(--bg-0); color:var(--fg-0); }
|
||||
.tab.active::after { content:""; position:absolute; left:0; right:0; top:0; height:2px; background:var(--accent); }
|
||||
.tab .tname { overflow:hidden; text-overflow:ellipsis; }
|
||||
.tab.dirty .tname::after { content:" ●"; color:var(--mod); font-size:10px; }
|
||||
.tab .tclose { width:17px; height:17px; border-radius:4px; display:flex; align-items:center; justify-content:center; color:var(--fg-3); flex:0 0 17px; }
|
||||
.tab .tclose:hover { background:var(--active); color:var(--fg-0); }
|
||||
.tab .tdot { display:none; width:7px; height:7px; border-radius:50%; background:var(--fg-2); }
|
||||
.tab.dirtyclose .tclose { display:none; }
|
||||
.tab.dirtyclose:hover .tclose { display:flex; }
|
||||
.tab.dirtyclose:hover .tdot { display:none; }
|
||||
.tab.dirtyclose .tdot { display:block; }
|
||||
.tab-mode { margin-left:6px; font-size:9.5px; letter-spacing:.05em; text-transform:uppercase; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; line-height:14px; }
|
||||
|
||||
.editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
|
||||
.editor { flex:1; overflow:auto; font-family:var(--mono); font-size:13px; line-height:20px; padding:6px 0 40px; }
|
||||
.ln-row { display:flex; align-items:flex-start; min-height:20px; }
|
||||
.ln-row.cursor { background:rgba(255,255,255,0.035); }
|
||||
.ln-row.add { background:var(--add-bg); }
|
||||
.ln-row.del { background:var(--del-bg); }
|
||||
.ln-row.selrange { background:var(--accent-soft); }
|
||||
.ln-gutter { flex:0 0 54px; width:54px; text-align:right; padding-right:14px; color:var(--fg-3); user-select:none; cursor:pointer; font-size:12px; }
|
||||
.ln-row.cursor .ln-gutter { color:var(--fg-1); }
|
||||
.ln-gutter:hover { color:var(--fg-1); }
|
||||
.ln-sign { flex:0 0 14px; width:14px; text-align:center; user-select:none; color:var(--fg-3); }
|
||||
.ln-row.add .ln-sign { color:var(--add); }
|
||||
.ln-row.del .ln-sign { color:var(--del); }
|
||||
.ln-code { flex:1; white-space:pre; padding:0 16px 0 6px; min-width:0; }
|
||||
.editor.diff .ln-code { padding-left:6px; }
|
||||
.empty-ed { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--fg-3); gap:14px; }
|
||||
.empty-ed .big { font-size:13px; }
|
||||
.empty-ed kbd { font-family:var(--mono); font-size:11px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; }
|
||||
.empty-ed .klist { display:flex; flex-direction:column; gap:9px; font-size:12px; }
|
||||
.empty-ed .klist div { display:flex; gap:10px; align-items:center; justify-content:space-between; min-width:230px; }
|
||||
|
||||
.diff-bar { height:26px; flex:0 0 26px; display:flex; align-items:center; gap:12px; padding:0 14px; background:var(--bg-3); border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-2); }
|
||||
.diff-bar .a{color:var(--add);font-family:var(--mono);} .diff-bar .d{color:var(--del);font-family:var(--mono);}
|
||||
.diff-bar .toggle { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:6px; overflow:hidden; }
|
||||
.diff-bar .toggle button { background:transparent; border:0; color:var(--fg-2); font:inherit; font-size:11px; padding:2px 10px; cursor:pointer; }
|
||||
.diff-bar .toggle button.on { background:var(--accent-soft); color:var(--fg-0); }
|
||||
|
||||
/* four-segment view control */
|
||||
.diff-bar .seg { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:7px; overflow:hidden; }
|
||||
.diff-bar .seg button { background:transparent; border:0; border-right:1px solid var(--border-2); color:var(--fg-2); font:inherit; font-size:11px; padding:3px 12px; cursor:pointer; display:flex; align-items:center; gap:6px; }
|
||||
.diff-bar .seg button:last-child { border-right:0; }
|
||||
.diff-bar .seg button:hover { color:var(--fg-0); background:var(--hover); }
|
||||
.diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); }
|
||||
.diff-bar .seg .split-btn svg { opacity:.85; }
|
||||
|
||||
/* gutter change bars (Original / Updated / Split) */
|
||||
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
|
||||
.ln-row.bar-add { box-shadow:inset 2px 0 0 var(--add); }
|
||||
.ln-row.empty { background:repeating-linear-gradient(45deg, rgba(255,255,255,0.015) 0 7px, transparent 7px 14px); }
|
||||
|
||||
/* full-screen split */
|
||||
.split-overlay { position:fixed; inset:0; z-index:60; background:var(--bg-0); display:flex; flex-direction:column; animation:tin .12s ease-out; }
|
||||
.split-head { height:42px; flex:0 0 42px; display:flex; align-items:center; gap:11px; padding:0 16px; background:var(--bg-3); border-bottom:1px solid var(--border); }
|
||||
.split-head .sh-name { font-family:var(--mono); font-size:13px; color:var(--fg-0); }
|
||||
.split-head .git-stat { font-size:11px; }
|
||||
.split-exit { margin-left:auto; display:flex; align-items:center; gap:7px; background:transparent; border:1px solid var(--border-2); border-radius:7px; color:var(--fg-1); font:inherit; font-size:12px; padding:5px 11px; cursor:pointer; }
|
||||
.split-exit:hover { background:var(--hover); color:var(--fg-0); }
|
||||
.split-exit kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
|
||||
.split-body { flex:1; display:flex; min-height:0; }
|
||||
.split-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
|
||||
.split-pane.left { border-right:1px solid var(--border-2); }
|
||||
.split-label { height:27px; flex:0 0 27px; display:flex; align-items:center; gap:9px; padding:0 16px; font-size:10.5px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-2); background:var(--bg-2); border-bottom:1px solid var(--border); }
|
||||
.split-label span { text-transform:none; letter-spacing:0; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
|
||||
|
||||
/* syntax token colors */
|
||||
.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important{color:var(--t-key);}
|
||||
.ln-code .token.string,.ln-code .token.attr-value,.ln-code .token.char,.ln-code .token.regex{color:var(--t-str);}
|
||||
.ln-code .token.number,.ln-code .token.unit{color:var(--t-num);}
|
||||
.ln-code .token.function,.ln-code .token.method{color:var(--t-fn);}
|
||||
.ln-code .token.comment,.ln-code .token.prolog,.ln-code .token.doctype,.ln-code .token.cdata{color:var(--t-com);font-style:italic;}
|
||||
.ln-code .token.tag{color:var(--t-tag);}
|
||||
.ln-code .token.attr-name{color:var(--t-attr);}
|
||||
.ln-code .token.punctuation{color:var(--t-punc);}
|
||||
.ln-code .token.operator{color:var(--t-punc);}
|
||||
.ln-code .token.variable,.ln-code .token.symbol{color:var(--t-var);}
|
||||
.ln-code .token.constant,.ln-code .token.boolean,.ln-code .token.builtin{color:var(--t-const);}
|
||||
.ln-code .token.property,.ln-code .token.property-access{color:var(--t-prop);}
|
||||
.ln-code .token.class-name,.ln-code .token.maybe-class-name{color:var(--t-attr);}
|
||||
.ln-code .token.parameter{color:var(--fg-0);}
|
||||
.ln-code .token.namespace{color:var(--fg-2);}
|
||||
.ln-code .token.selector{color:var(--t-tag);}
|
||||
.ln-code .token.entity,.ln-code .token.url{color:var(--t-prop);}
|
||||
.ln-code .token.deleted{color:var(--del);} .ln-code .token.inserted{color:var(--add);}
|
||||
|
||||
/* ============ terminals (right column) ============ */
|
||||
.term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); }
|
||||
.term-head { height:28px; flex:0 0 28px; display:flex; align-items:center; gap:8px; padding:0 10px; background:var(--bg-3); border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
|
||||
.term-head .dot { width:7px; height:7px; border-radius:50%; background:var(--fg-3); }
|
||||
.term-head .dot.live { background:var(--add); box-shadow:0 0 0 0 rgba(92,189,107,.5); animation:pulse 2.2s infinite; }
|
||||
@keyframes pulse { 0%{box-shadow:0 0 0 0 rgba(92,189,107,.45);} 70%{box-shadow:0 0 0 5px rgba(92,189,107,0);} 100%{box-shadow:0 0 0 0 rgba(92,189,107,0);} }
|
||||
.term-head .lbl { color:var(--fg-1); font-family:var(--mono); }
|
||||
.term-head .tag { margin-left:auto; font-size:10px; color:var(--fg-3); font-family:var(--mono); }
|
||||
.term-body { flex:1; overflow:auto; padding:8px 12px 12px; font-family:var(--mono); font-size:12.5px; line-height:18px; cursor:text; }
|
||||
.tline { white-space:pre-wrap; word-break:break-word; }
|
||||
.tline.dim{color:var(--fg-3);} .tline.acc{color:var(--accent);} .tline.ok{color:var(--add);} .tline.warn{color:var(--mod);} .tline.err{color:var(--del);}
|
||||
.tline .pfx { color:var(--accent); }
|
||||
.tline .ag { color:#c98bdb; }
|
||||
.tline .tool { color:var(--mod); }
|
||||
.tline .fp { color:var(--t-prop); }
|
||||
.term-card { border:1px solid var(--border-2); border-radius:7px; padding:7px 10px; margin:5px 0; background:rgba(255,255,255,0.02); }
|
||||
.term-card .ch { color:var(--fg-2); font-size:11px; margin-bottom:4px; display:flex; gap:7px; align-items:center; min-width:0; }
|
||||
.term-card .ch span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.term-card .add,.term-card .del { white-space:pre-wrap; word-break:break-word; line-height:17px; }
|
||||
.term-card .add{color:var(--add);} .term-card .del{color:var(--del);}
|
||||
.term-input { display:flex; align-items:center; gap:8px; }
|
||||
.term-input .ip { color:var(--accent); }
|
||||
.term-input .ip.ag { color:#c98bdb; }
|
||||
.term-input input { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-family:var(--mono); font-size:12.5px; caret-color:var(--accent); }
|
||||
.cursor-blink { display:inline-block; width:7px; height:14px; background:var(--accent); margin-left:1px; animation:blink 1.1s step-end infinite; vertical-align:-2px; }
|
||||
@keyframes blink { 50%{opacity:0;} }
|
||||
|
||||
/* ============ overlays ============ */
|
||||
.scrim { position:fixed; inset:0; background:rgba(8,9,11,0.5); z-index:50; display:flex; justify-content:center; align-items:flex-start; padding-top:90px; backdrop-filter:blur(1.5px); }
|
||||
.palette { width:620px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; }
|
||||
.palette .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
|
||||
.palette .pi input { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-size:15px; font-family:var(--ui); }
|
||||
.palette .pi .mode-chip { font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); }
|
||||
.palette .results { max-height:380px; overflow:auto; padding:6px; }
|
||||
.pres { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px; cursor:pointer; }
|
||||
.pres.sel { background:var(--accent-soft); }
|
||||
.pres .pn { font-size:13px; color:var(--fg-0); }
|
||||
.pres .pn b { color:var(--accent); font-weight:700; }
|
||||
.pres .pp { font-size:11px; color:var(--fg-3); margin-left:auto; font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:55%; direction:rtl; }
|
||||
.pempty { padding:26px; text-align:center; color:var(--fg-3); font-size:12.5px; }
|
||||
|
||||
/* combined search modal (content + files) */
|
||||
.search-modal { width:940px; max-width:94vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
|
||||
.search-cols { display:flex; min-height:0; }
|
||||
.sc-left { flex:1 1 auto; min-width:0; max-height:460px; overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; }
|
||||
.sc-right { flex:0 0 256px; min-width:0; max-height:460px; overflow:auto; padding-bottom:8px; background:rgba(0,0,0,0.12); }
|
||||
.sc-head { position:sticky; top:0; z-index:1; background:#212429; padding:9px 14px 6px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:7px; }
|
||||
.sc-right .sc-head { background:#1e2024; }
|
||||
.sc-head .sc-ct { color:var(--fg-2); background:var(--bg-3); border:1px solid var(--border); border-radius:9px; padding:0 7px; line-height:15px; font-size:10px; }
|
||||
.srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; }
|
||||
.fres { display:flex; align-items:center; gap:9px; padding:6px 12px; cursor:pointer; }
|
||||
.fres:hover { background:var(--hover); }
|
||||
.fres-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
|
||||
.fres-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.fres-txt .fn b { color:var(--accent); font-weight:700; }
|
||||
.fres-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
|
||||
/* content search */
|
||||
.search-results { max-height:420px; overflow:auto; padding:4px 0 8px; }
|
||||
.sr-file { padding:7px 14px 3px; font-size:11.5px; color:var(--fg-2); display:flex; align-items:center; gap:8px; cursor:pointer; }
|
||||
.sr-file:hover { color:var(--fg-0); }
|
||||
.sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
|
||||
.sr-line { display:flex; gap:12px; padding:2px 14px 2px 38px; font-family:var(--mono); font-size:12px; cursor:pointer; color:var(--fg-1); }
|
||||
.sr-line:hover { background:var(--hover); }
|
||||
.sr-line .no { color:var(--fg-3); min-width:34px; text-align:right; }
|
||||
.sr-line .tx { white-space:pre; overflow:hidden; text-overflow:ellipsis; }
|
||||
.sr-line mark { background:rgba(216,168,92,.28); color:var(--fg-0); border-radius:2px; }
|
||||
|
||||
/* pass-on-to-agent inline popup */
|
||||
.pass-pop { position:fixed; z-index:85; width:344px; max-width:92vw; background:#23272d; border:1px solid var(--border-2); border-radius:10px; box-shadow:0 18px 48px rgba(0,0,0,.55); padding:11px; animation:popin .12s ease-out; }
|
||||
@keyframes popin { from { transform:translateY(6px); } }
|
||||
.pass-head { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--fg-1); margin-bottom:9px; }
|
||||
.pass-head svg { color:#c98bdb; }
|
||||
.pass-head .pass-esc { margin-left:auto; font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 6px; }
|
||||
.pass-input { width:100%; box-sizing:border-box; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:13px; padding:8px 10px; outline:none; }
|
||||
.pass-input:focus { border-color:var(--accent-line); }
|
||||
.pass-input::placeholder { color:var(--fg-3); }
|
||||
.pass-preview { margin-top:9px; display:flex; align-items:center; gap:8px; min-width:0; }
|
||||
.pass-preview .pp-lbl { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--fg-3); flex:0 0 auto; }
|
||||
.pass-preview code { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:3px 7px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pass-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); }
|
||||
.pass-foot kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
|
||||
|
||||
/* terminal multi-line input */
|
||||
.term-input { align-items:flex-start; }
|
||||
.term-ta { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-family:var(--mono); font-size:12.5px; line-height:18px; caret-color:var(--accent); resize:none; padding:0; margin:0; overflow:hidden; }
|
||||
.term-ta::placeholder { color:var(--fg-3); }
|
||||
|
||||
/* context menu */.ctx { position:fixed; z-index:80; background:#23272d; border:1px solid var(--border-2); border-radius:9px; padding:5px; min-width:248px; box-shadow:0 16px 44px rgba(0,0,0,.5); }
|
||||
.ctx-item { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:6px; cursor:pointer; font-size:12.5px; color:var(--fg-1); }
|
||||
.ctx-item:hover { background:var(--accent-soft); color:var(--fg-0); }
|
||||
.ctx-item .kc { margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); }
|
||||
.ctx-item.primary { color:var(--fg-0); }
|
||||
.ctx-item.primary .ic { color:var(--accent); }
|
||||
.ctx-item .ic { width:15px; display:flex; justify-content:center; color:var(--fg-3); }
|
||||
.ctx-sep { height:1px; background:var(--border); margin:5px 6px; }
|
||||
.ctx-note { padding:4px 11px 7px; font-size:10.5px; color:var(--fg-3); font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
/* toast */
|
||||
.toast-wrap { position:fixed; bottom:34px; left:50%; transform:translateX(-50%); z-index:90; display:flex; flex-direction:column; gap:8px; align-items:center; }
|
||||
.toast { background:#23272d; border:1px solid var(--border-2); border-left:3px solid var(--accent); border-radius:9px; padding:9px 14px; box-shadow:0 12px 34px rgba(0,0,0,.45); display:flex; align-items:center; gap:11px; animation:tin .18s ease-out; }
|
||||
@keyframes tin { from{opacity:0; transform:translateY(8px);} }
|
||||
.toast .tt { font-size:12.5px; color:var(--fg-0); }
|
||||
.toast .tref { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:2px 8px; }
|
||||
|
||||
/* ============ status bar ============ */
|
||||
.statusbar { height:23px; flex:0 0 23px; display:flex; align-items:center; gap:0; background:var(--bg-3); border-top:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
|
||||
.sb { display:flex; align-items:center; gap:6px; padding:0 11px; height:100%; }
|
||||
.sb:hover { background:var(--hover); }
|
||||
.sb.accent { background:var(--accent); color:#0c1320; }
|
||||
.sb.accent:hover { background:#5d97ff; }
|
||||
.sb.spacer { flex:1; }
|
||||
.sb .a{color:var(--add);} .sb .d{color:var(--del);}
|
||||
.sb b { font-weight:600; color:var(--fg-1); }
|
||||
Reference in New Issue
Block a user