/* 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 }}
›
{{ s }}
{{ PROJECT.branch }}
+{{ totals.add }}
−{{ totals.del }}
{{ selection.end - selection.start + 1 }} lines selected
Ln {{ cursor.path === active ? cursor.line : 1 }}, Col {{ cursor.path === active ? cursor.col : 1 }}
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");