/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference, status bar */ const { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } = Vue; /* ---- Helpers ---- */ 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; } /* ---- Splitter ---- */ const Splitter = { props: { orientation: { type: String, default: "v" }, onDelta: { type: Function, required: true }, }, setup(props) { const drag = ref(false); function down(e) { e.preventDefault(); let last = { x: e.clientX, y: e.clientY }; drag.value = true; document.body.style.cursor = props.orientation === "v" ? "col-resize" : "row-resize"; document.body.style.userSelect = "none"; function mv(ev) { props.onDelta(ev.clientX - last.x, ev.clientY - last.y); last = { x: ev.clientX, y: ev.clientY }; } function up() { drag.value = 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 { drag, down }; }, template: `
`, }; /* ---- RightColumn ---- */ const RightColumn = { props: { width: { type: Number, required: true }, }, setup(props) { const topFrac = ref(0.52); const colRef = ref(null); function delta(dx, dy) { const h = colRef.value ? colRef.value.clientHeight : 600; topFrac.value = Math.max(0.18, Math.min(0.82, (topFrac.value * h + dy) / h)); } const agent = agentSeed(); const shell = shellSeed(); return { topFrac, colRef, delta, agent, shell }; }, template: `
`, }; /* ---- App ---- */ const App = { setup() { /* --- derived constants (no reactivity needed) --- */ const changeMap = computed(() => Object.fromEntries(PROJECT.changes.map((c) => [c.path, c.status]))); const changeSet = computed(() => new Set(PROJECT.changes.map((c) => c.path))); /* --- state --- */ const tabs = ref([ { path: "src/Http/Controller/UserController.php" }, { path: "public/assets/app.js" }, { path: "src/types/api.ts" }, ]); const active = ref("src/Http/Controller/UserController.php"); const tabMode = ref({ "src/Http/Controller/UserController.php": "diff" }); const openDirs = ref(initialOpenDirs(PROJECT.tree, new Set())); const cursor = ref({ path: "src/Http/Controller/UserController.php", line: 1, col: 1 }); const selection = ref(null); const overlay = ref(null); const menu = ref(null); const toasts = ref([]); const splitFor = ref(null); const staged = ref(new Set(["src/Service/PaymentService.php", "config/app.json"])); const committed = ref(new Set()); const commitMsg = ref(""); const passPopup = ref(null); const gitW = ref(232); const treeW = ref(244); const rightW = ref(444); /* --- computed --- */ const MODE_LABEL = { original: "orig", updated: "upd", diff: "diff", code: "" }; const MODE_WORD = { original: "Original", updated: "Updated", diff: "Diff" }; const resolvedTabs = computed(() => tabs.value.map((t) => { const changed = !!PROJECT.diffs[t.path]; const m = tabMode.value[t.path] || (changed ? "diff" : "code"); return { ...t, changed, modeLabel: splitFor.value === t.path ? "split" : MODE_LABEL[m] }; }) ); const mode = computed(() => tabMode.value[active.value] || (PROJECT.diffs[active.value] ? "diff" : "code")); const totals = computed(() => PROJECT.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })); const activeLang = computed(() => active.value ? HL.langLabel(active.value) : ""); const crumb = computed(() => active.value ? active.value.split("/") : []); /* --- helpers --- */ function toast(title, refText) { const id = lid(); toasts.value = [...toasts.value, { id, title, ref: refText }]; setTimeout(() => { toasts.value = toasts.value.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); } function toggleDir(p) { const n = new Set(openDirs.value); n.has(p) ? n.delete(p) : n.add(p); openDirs.value = n; } function reveal(path) { const n = new Set(openDirs.value); ancestors(path).forEach((a) => n.add(a)); openDirs.value = n; } function stage(p) { const n = new Set(staged.value); n.add(p); staged.value = n; } function unstage(p) { const n = new Set(staged.value); n.delete(p); staged.value = n; } function stageAll() { staged.value = new Set(PROJECT.changes.filter((c) => !committed.value.has(c.path)).map((c) => c.path)); } function unstageAll() { staged.value = new Set(); } function commit() { const list = PROJECT.changes.filter((c) => staged.value.has(c.path) && !committed.value.has(c.path)); if (!list.length || !commitMsg.value.trim()) return; const n = new Set(committed.value); list.forEach((c) => n.add(c.path)); committed.value = n; staged.value = new Set(); const msg = commitMsg.value.trim(); commitMsg.value = ""; 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]; if (!tabs.value.some((x) => x.path === path)) { tabs.value = [...tabs.value, { path }]; } active.value = path; if (opts.diff && changed) { tabMode.value = { ...tabMode.value, [path]: "diff" }; } else if (!tabMode.value[path]) { tabMode.value = { ...tabMode.value, [path]: changed ? "diff" : "code" }; } reveal(path); if (opts.line) { // show the current/updated file so line numbers map to search hits splitFor.value = null; tabMode.value = { ...tabMode.value, [path]: changed ? "updated" : "code" }; cursor.value = { path, line: opts.line, col: 1 }; selection.value = 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) { const t = tabs.value; const ix = t.findIndex((x) => x.path === path); const next = t.filter((x) => x.path !== path); if (path === active.value) { const fallback = next[ix] || next[ix - 1] || next[next.length - 1]; active.value = fallback ? fallback.path : null; } tabs.value = next; } /* ---- context menus ---- */ function openMenu(e, target) { e.preventDefault(); e.stopPropagation(); const sparkSend = (ref) => ({ 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; menu.value = { x: mx, y: my, note: ref, items: [ { primary: true, icon: "copy", label: "Copy reference", onClick: () => copyText(ref) }, { icon: "spark", label: "Pass on to Agent", onClick: () => { passPopup.value = { 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: "copy", label: "Copy reference", onClick: () => copyText(ref) }, sparkSend(ref), { 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.value.has(target.path); items.push(isStaged ? { icon: "minus", label: "Unstage changes", onClick: () => unstage(target.path) } : { icon: "plus", label: "Stage changes", onClick: () => stage(target.path) }); items.push({ icon: "diff", label: "Open diff", onClick: () => openFile(target.path, { diff: true }) }); } items.push({ icon: "file", label: "Open file", onClick: () => openFile(target.path) }); items.push({ icon: "reveal", label: "Reveal in Explorer", onClick: () => reveal(target.path) }); } menu.value = { x: e.clientX, y: e.clientY, note: ref, items }; } } /* ---- keyboard shortcuts ---- */ function onKey(e) { const meta = e.metaKey || e.ctrlKey; if (meta && e.key.toLowerCase() === "f") { e.preventDefault(); overlay.value = "search"; } else if (meta && e.key.toLowerCase() === "w") { e.preventDefault(); if (active.value) closeTab(active.value); } else if (e.key === "Escape") { if (splitFor.value) splitFor.value = null; else { overlay.value = null; menu.value = null; } } } onMounted(() => window.addEventListener("keydown", onKey)); onUnmounted(() => window.removeEventListener("keydown", onKey)); /* --- passPopup confirm handler (passed as prop) --- */ function onPassConfirm(text) { const line = (text && text.trim() ? text.trim() + " " : "") + passPopup.value.ref; window.dispatchEvent(new CustomEvent("agentPaste", { detail: line })); const savedRef = passPopup.value.ref; passPopup.value = null; toast("Passed to agent", savedRef); } function onPassCancel() { passPopup.value = null; } function setCommitMsg(v) { commitMsg.value = v; } function setActive(p) { active.value = p; } function setMode(m) { tabMode.value = { ...tabMode.value, [active.value]: m }; splitFor.value = null; } function setCursorVal(v) { cursor.value = v; } function setSelectionVal(v) { selection.value = v; } function setSplitFor(p) { splitFor.value = p; } function clampGitW(dx) { gitW.value = clamp(gitW.value + dx, 160, 460); } function clampTreeW(dx) { treeW.value = clamp(treeW.value + dx, 160, 520); } function clampRightW(dx) { rightW.value = clamp(rightW.value - dx, 280, 780); } return { changeMap, changeSet, tabs, active, tabMode, openDirs, cursor, selection, overlay, menu, toasts, splitFor, staged, committed, commitMsg, passPopup, gitW, treeW, rightW, resolvedTabs, mode, totals, activeLang, crumb, MODE_LABEL, MODE_WORD, toast, copyText, toggleDir, reveal, stage, unstage, stageAll, unstageAll, commit, openFile, closeTab, openMenu, onPassConfirm, onPassCancel, setCommitMsg, setActive, setMode, setCursorVal, setSelectionVal, setSplitFor, clampGitW, clampTreeW, clampRightW, PROJECT, /* expose global so template can reference it */ }; }, template: `
Helder {{ PROJECT.name }}
{{ PROJECT.branch }}
+{{ totals.add }} −{{ totals.del }}
Spaces: 4
UTF-8
{{ activeLang }}
{{ splitFor === active ? 'Split' : (MODE_WORD[mode] || '') }}
`, }; /* ---- Mount ---- */ const app = Vue.createApp(App); Object.entries(window.HelderComponents).forEach(([n, c]) => app.component(n, c)); app.component("Splitter", Splitter); app.component("RightColumn", RightColumn); app.mount("#app");