init helder

This commit is contained in:
2026-06-15 22:33:46 +02:00
parent 3d77fdfeff
commit 3f5078841d
38 changed files with 6349 additions and 2083 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
out/
dist/
.DS_Store
*.log
*.tsbuildinfo
.playwright-mcp/

View File

@@ -4,7 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project state ## Project state
This is a **greenfield project**. No application code, build setup, or `package.json` exists yet — only a design handoff. The first real task is to scaffold the Electron app and port the prototype. Until then, treat the two handoff documents as the contract: **Phase 1 is scaffolded.** An `electron-vite` + React 18 + TypeScript app now lives at the repo root (`src/main`, `src/preload`, `src/renderer`). The prototype has been ported faithfully and renders against the **mock data** — full UI, git panel, four diff modes + Split, search, and the *simulated* terminals are all working. JetBrains Mono is bundled locally via `@fontsource/jetbrains-mono`; Prism is wired with the correct `markup-templating``php` load order; the renderer↔main clipboard bridge is in place (`src/preload/index.ts`).
**Phase 2 (real integrations) — essentially complete.** All over IPC through the preload bridge (`src/preload/index.ts`):
- **Filesystem** — tree, in-memory content index, `chokidar` watch (`src/main/fs-service.ts`).
- **Git** — `simple-git`: status→A/M/D/R, the four diff views from HEAD-vs-worktree pairs, stage/unstage/commit/discard (`src/main/git-service.ts`).
- **Terminals** — real PTYs via `node-pty` (`src/main/pty-service.ts`) rendered with `@xterm/xterm` (`src/renderer/src/terminals.tsx`). Agent pane is a shell that auto-launches `claude`; bottom pane is a plain shell. Pass-on-to-Agent writes bracketed paste (`\x1b[200~ … \x1b[201~`) to the agent PTY. node-pty is native — `npm run rebuild` (also a `postinstall`) rebuilds it for Electron; it's N-API so the binary is portable.
- **Config** — `.helder/` per project (`src/main/config.ts`): `config.default.json` regenerated on launch (full defaults / live docs), sparse `config.json` deep-merged over it, and `theme.css` (created once, never overwritten) injected over the built-in dark theme. The **code font + size are CSS vars** (`--code-font`/`--code-size`/`--term-size`) the editor + xterm read, overridable from `theme.css`. ai command/autoLaunch + shell flow from config into the PTYs; a `.helder` file watcher hot-reloads config/theme.
- **Search** — ripgrep (`@vscode/ripgrep`, bundled binary) for content (`--json`, fixed-string smart-case) and the file-name list (`--files`), via `src/main/search-service.ts`. `SearchModal` calls it debounced and falls back to the in-memory index when `window.helder` is absent.
The renderer consumes FS/git/config/search via the store in `src/renderer/src/project.tsx` (`useProject` / `useProjectActions`), which falls back to the mock + default config when `window.helder` is absent (browser preview). `src/main/index.ts` registers all IPC handlers + the debounced watchers.
- **Editing** — the `code`/`updated` modes are a writable buffer: a transparent textarea over a Prism-highlighted `<pre>` with a scroll-synced gutter (`CodeEditor` in `editor.tsx`). `⌘S` saves to disk (`fs:write`), `editor.autoSave` debounce-saves on change, tabs show the dirty dot, and the git-row context menu has **Discard changes** gated by `git.confirmDiscard`. Original/Diff/Split stay read-only review views.
README steps 17 plus config + editing are all implemented for real. The editable overlay keeps the caret in view (the textarea is overflow-hidden under the scroller, so `CodeEditor` scrolls the container on input/keyup/click).
Not yet done: packaging (electron-builder → .app/.dmg) — `out/` is dev build output only, there is no distributable yet.
The two handoff documents remain the contract:
- **`DESIGN.md`** — functional/UX source of truth. Every panel, interaction, state, and edge case at the behavior level. Read this for *what the app does*. - **`DESIGN.md`** — functional/UX source of truth. Every panel, interaction, state, and edge case at the behavior level. Read this for *what the app does*.
- **`design_handoff_helder_workbench/README.md`** — technical source of truth. Structure, design tokens, recommended stack, real-integration mechanics, and the suggested implementation order. Read this for *how to build it*. - **`design_handoff_helder_workbench/README.md`** — technical source of truth. Structure, design tokens, recommended stack, real-integration mechanics, and the suggested implementation order. Read this for *how to build it*.
@@ -69,4 +88,9 @@ Canonical source is the `:root` block in `design_handoff_helder_workbench/design
## Commands ## Commands
No build/lint/test commands exist yet. Once the Electron + Vite toolchain is scaffolded, document the real `dev` / `build` / `lint` / `test` commands here, replacing this note. - `npm run dev` — launch the app in Electron with HMR (`electron-vite dev`).
- `npm run build` — type-stripped production build into `out/` (`electron-vite build`). A frontend change is not done until this succeeds.
- `npm run preview` / `npm start` — run the built app (`electron-vite preview`).
- `npm run typecheck``tsc --noEmit` over the renderer (`tsconfig.web.json`) and main/preload (`tsconfig.node.json`). The build itself uses esbuild and does NOT type-check, so run this separately to catch type errors.
No test/lint runner is wired up yet — add and document them here when introduced.

View File

@@ -1,40 +0,0 @@
<!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>
<!-- Vue 3 global build (runtime template compiler included) -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- data + helpers (plain JS) -->
<script src="src/data.js"></script>
<script src="src/highlight.js"></script>
</head>
<body>
<div id="app"></div>
<!-- components (plain JS, no build) -->
<script src="src/components.js"></script>
<script src="src/editor.js"></script>
<script src="src/terminals.js"></script>
<script src="src/overlays.js"></script>
<script src="src/app.js"></script>
</body>
</html>

View File

@@ -1,464 +0,0 @@
/* 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: `<div :class="'splitter' + (orientation === 'h' ? ' h' : '') + (drag ? ' drag' : '')" @mousedown="down" />`,
};
/* ---- 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: `
<div class="col right-col" :style="{ width: width + 'px', flex: '0 0 ' + width + 'px' }">
<div ref="colRef" :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>
`,
};
/* ---- 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: `
<div class="app">
<!-- title bar -->
<div class="titlebar">
<div class="traffic"><i class="r" /><i class="y" /><i class="g" /></div>
<div class="tb-title">
<Icon name="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>
<div v-if="active" class="tb-crumb">
<template v-for="(s, i) in crumb" :key="i">
<span v-if="i > 0" class="seg"> </span>
<span :style="i === crumb.length - 1 ? { color: 'var(--fg-1)' } : null">{{ s }}</span>
</template>
</div>
<div class="tb-spacer" />
<div class="tb-actions">
<button class="tb-btn" @click="overlay = 'search'">
<Icon name="search" /> Search <kbd>⌘F</kbd>
</button>
</div>
</div>
<!-- workbench -->
<div class="workbench">
<div class="col" :style="{ width: gitW + 'px', 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="clampGitW" />
<div class="col" :style="{ width: treeW + 'px', 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="clampTreeW" />
<div class="col editor-col">
<Editor
:tabs="resolvedTabs"
:active="active"
:mode="mode"
:setMode="setMode"
:onActivate="setActive"
:onClose="closeTab"
:onContext="openMenu"
:onSplit="setSplitFor"
:splitOpen="splitFor === active"
:cursor="cursor"
:selection="selection"
:setCursor="setCursorVal"
:setSelection="setSelectionVal" />
</div>
<Splitter :onDelta="clampRightW" />
<RightColumn :width="rightW" />
</div>
<!-- status bar -->
<div class="statusbar">
<div class="sb accent">
<Icon name="branch" :w="12" :h="12" />
<span style="color:#0c1320">{{ PROJECT.branch }}</span>
</div>
<div class="sb">
<span class="a">+{{ totals.add }}</span>
<span class="d"> {{ totals.del }}</span>
</div>
<div class="sb spacer" />
<div v-if="active" class="sb">
<template v-if="selection && selection.path === active && selection.start !== selection.end">
{{ selection.end - selection.start + 1 }} lines selected
</template>
<template v-else>
Ln {{ cursor.path === active ? cursor.line : 1 }}, Col {{ cursor.path === active ? cursor.col : 1 }}
</template>
</div>
<div v-if="active" class="sb">Spaces: 4</div>
<div v-if="active" class="sb">UTF-8</div>
<div v-if="active" class="sb"><b>{{ activeLang }}</b></div>
<div v-if="active && PROJECT.diffs[active]" class="sb">
{{ splitFor === active ? 'Split' : (MODE_WORD[mode] || '') }}
</div>
</div>
<!-- overlays -->
<SplitView v-if="splitFor" :path="splitFor" :onClose="() => { setSplitFor(null); }" :onContext="openMenu" />
<PassPopup
v-if="passPopup"
:x="passPopup.x"
:y="passPopup.y"
:refStr="passPopup.ref"
:onConfirm="onPassConfirm"
:onCancel="onPassCancel" />
<SearchModal
v-if="overlay === 'search'"
:onOpen="openFile"
:onOpenAt="(p, n) => openFile(p, { line: n })"
:onClose="() => { overlay = null; }"
:changeSet="changeSet" />
<ContextMenu v-if="menu" :menu="menu" :onClose="() => { menu = null; }" />
<Toasts :toasts="toasts" />
</div>
`,
};
/* ---- 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");

View File

@@ -1,395 +0,0 @@
/* Shared icons, FileIcon, GitPanel, FileTree — Vue 3 classic-script port */
const { ref, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
/* ---- Icon component ---- */
/* Internal map: name → { vb, inner } */
const _iconMap = {
search: {
vb: "0 0 16 16",
inner: `<circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.4"/><line x1="10.5" y1="10.5" x2="14" y2="14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>`
},
branch: {
vb: "0 0 16 16",
inner: `<circle cx="4" cy="3.5" r="1.8" stroke="currentColor" stroke-width="1.3"/><circle cx="4" cy="12.5" r="1.8" stroke="currentColor" stroke-width="1.3"/><circle cx="12" cy="5" r="1.8" stroke="currentColor" stroke-width="1.3"/><path d="M4 5.3v5.4M5.8 5C9 5 10 6.2 10 9v0" stroke="currentColor" stroke-width="1.3" fill="none"/>`
},
close: {
vb: "0 0 12 12",
inner: `<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>`
},
copy: {
vb: "0 0 16 16",
inner: `<rect x="5" y="5" width="8" height="9" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M3 11V3a1 1 0 0 1 1-1h6" stroke="currentColor" stroke-width="1.3" fill="none"/>`
},
terminal: {
vb: "0 0 16 16",
inner: `<path d="M3 4l3 3-3 3M8 11h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`
},
spark: {
vb: "0 0 16 16",
inner: `<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" stroke-width="1.1" fill="none" stroke-linejoin="round"/>`
},
file: {
vb: "0 0 16 16",
inner: `<path d="M4 2h5l3 3v9H4V2z" stroke="currentColor" stroke-width="1.2" fill="none"/><path d="M9 2v3h3" stroke="currentColor" stroke-width="1.2" fill="none"/>`
},
reveal: {
vb: "0 0 16 16",
inner: `<path d="M2 4.5h4l1.3 1.5H14V13H2V4.5z" stroke="currentColor" stroke-width="1.2" fill="none"/>`
},
diff: {
vb: "0 0 16 16",
inner: `<path d="M4 2v8M4 12.5v1.5M2 4h4M2 8h4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><path d="M12 14V6M12 3.5V2M10 12h4M10 8h4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>`
},
plus: {
vb: "0 0 14 14",
inner: `<path d="M7 2.5v9M2.5 7h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>`
},
minus: {
vb: "0 0 14 14",
inner: `<path d="M2.5 7h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>`
},
check: {
vb: "0 0 14 14",
inner: `<path d="M2.5 7.5l2.8 3L11.5 3.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`
},
discard: {
vb: "0 0 16 16",
inner: `<path d="M12.5 5.5A5 5 0 1 0 13 9" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round"/><path d="M12.5 2.5v3h-3" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`
},
};
const Icon = {
props: {
name: { type: String, required: true },
w: { type: Number, default: 13 },
h: { type: Number, default: 13 },
},
setup(props) {
const entry = computed(() => _iconMap[props.name] || null);
const vb = computed(() => entry.value ? entry.value.vb : "0 0 16 16");
const inner = computed(() => entry.value ? entry.value.inner : "");
return { vb, inner };
},
template: `<svg :width="w" :height="h" :viewBox="vb" fill="none" v-html="inner"></svg>`,
};
/* ---- Chevron ---- */
const Chevron = {
props: {
open: { type: Boolean, default: false },
},
template: `
<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" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`,
};
/* ---- FolderIcon ---- */
const FolderIcon = {
props: {
open: { type: Boolean, default: false },
},
template: `
<svg class="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" stroke-width="1.1"/>
</svg>
`,
};
/* ---- FileIcon ---- */
const FileIcon = {
props: {
path: { type: String, required: true },
},
setup(props) {
const ic = computed(() => HL.iconFor(props.path));
return { ic };
},
template: `
<span class="ficon" :style="{ background: ic.c }"><span>{{ ic.t }}</span></span>
`,
};
/* ---- GitRow ---- */
const GitRow = {
props: {
c: { type: Object, required: true },
staged: { type: Boolean, required: true },
activePath: { type: String, default: null },
onOpen: { type: Function, required: true },
onContext: { type: Function, required: true },
onToggleStage: { type: Function, required: true },
},
components: { Icon, FileIcon },
setup(props) {
const name = computed(() => props.c.path.split("/").pop());
const dir = computed(() => props.c.path.split("/").slice(0, -1).join("/"));
return { name, dir };
},
template: `
<div
:class="'git-row' + (activePath === c.path ? ' active' : '')"
@click="onOpen(c.path, { diff: true })"
@contextmenu="(e) => onContext(e, { path: c.path, kind: 'git', staged })"
:title="c.path">
<span :class="'git-stat ' + c.status">{{ c.status }}</span>
<FileIcon :path="c.path" />
<span :class="'git-name' + (c.deleted ? ' del' : '')">{{ name }}</span>
<span v-if="dir" class="git-dir">{{ dir }}/</span>
<button
class="git-act"
:title="staged ? 'Unstage changes' : 'Stage changes'"
@click.stop="onToggleStage(c.path)">
<Icon v-if="staged" name="minus" />
<Icon v-else name="plus" />
</button>
<span class="git-delta">
<span v-if="c.add > 0" class="a">+{{ c.add }}</span>
<span v-if="c.del > 0" class="d">-{{ c.del }}</span>
</span>
</div>
`,
};
/* ---- GitPanel (multi-root fragment) ---- */
const GitPanel = {
props: {
changes: { type: Array, required: true },
staged: { type: Object, required: true }, /* Set */
committed: { type: Object, required: true }, /* Set */
commitMsg: { type: String, required: true },
setCommitMsg: { type: Function, required: true },
onStage: { type: Function, required: true },
onUnstage: { type: Function, required: true },
onStageAll: { type: Function, required: true },
onUnstageAll: { type: Function, required: true },
onCommit: { type: Function, required: true },
onOpen: { type: Function, required: true },
onContext: { type: Function, required: true },
activePath: { type: String, default: null },
},
components: { Icon, GitRow },
setup(props) {
const visible = computed(() => props.changes.filter((c) => !props.committed.has(c.path)));
const stagedList = computed(() => visible.value.filter((c) => props.staged.has(c.path)));
const changesList = computed(() => visible.value.filter((c) => !props.staged.has(c.path)));
const totals = computed(() =>
visible.value.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
);
const canCommit = computed(() => stagedList.value.length > 0 && props.commitMsg.trim().length > 0);
function handleKeyDown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && canCommit.value) {
e.preventDefault();
props.onCommit();
}
}
return { visible, stagedList, changesList, totals, canCommit, handleKeyDown };
},
/* Vue 3 allows multi-root templates */
template: `
<div class="phead">
<Icon name="branch" /><span>Source Control</span>
<span class="ct">{{ visible.length }}</span>
</div>
<div class="commit-box">
<textarea
class="commit-input"
rows="1"
:value="commitMsg"
:spellcheck="false"
placeholder="Message (⌘↵ to commit)"
@input="(e) => setCommitMsg(e.target.value)"
@keydown="handleKeyDown">
</textarea>
<button
class="commit-btn"
:disabled="!canCommit"
@click="onCommit"
:title="canCommit ? 'Commit staged changes' : 'Stage files and write a message to commit'">
<Icon name="check" /><span>Commit{{ stagedList.length ? ' ' + stagedList.length : '' }}</span>
</button>
</div>
<div class="git-body">
<div v-if="visible.length === 0" class="git-empty">
<Icon name="check" :w="20" :h="20" /><span>No changes — working tree clean</span>
</div>
<template v-else>
<div class="git-group">
Staged Changes <span class="gc">{{ stagedList.length }}</span>
<button v-if="stagedList.length > 0" class="grp-act" title="Unstage all" @click="onUnstageAll">
<Icon name="minus" />
</button>
</div>
<template v-if="stagedList.length > 0">
<GitRow
v-for="c in stagedList"
:key="c.path"
:c="c"
:staged="true"
:activePath="activePath"
:onOpen="onOpen"
:onContext="onContext"
:onToggleStage="onUnstage" />
</template>
<div v-else class="git-none">Nothing staged — use <span class="key">+</span> to stage a file</div>
<div class="git-divider"></div>
<div class="git-group">
Changes <span class="gc">{{ changesList.length }}</span>
<button v-if="changesList.length > 0" class="grp-act" title="Stage all" @click="onStageAll">
<Icon name="plus" />
</button>
</div>
<template v-if="changesList.length > 0">
<GitRow
v-for="c in changesList"
:key="c.path"
:c="c"
:staged="false"
:activePath="activePath"
:onOpen="onOpen"
:onContext="onContext"
:onToggleStage="onStage" />
</template>
<div v-else class="git-none">All changes staged</div>
</template>
</div>
<div class="git-foot">
<span class="branch-chip"><Icon name="branch" /><b>{{ PROJECT.branch }}</b></span>
<span :style="{ marginLeft: 'auto', fontFamily: 'var(--mono)' }">
<span class="a" :style="{ color: 'var(--add)' }">+{{ totals.add }}</span>{{ ' ' }}
<span class="d" :style="{ color: 'var(--del)' }">-{{ totals.del }}</span>
</span>
</div>
`,
};
/* ---- TreeNode (recursive — registered globally) ---- */
const TreeNode = {
name: "TreeNode",
props: {
node: { type: Object, required: true },
depth: { type: Number, required: true },
openDirs: { type: Object, required: true }, /* Set */
toggleDir: { type: Function, required: true },
onOpen: { type: Function, required: true },
onContext: { type: Function, required: true },
activePath: { type: String, default: null },
changeMap: { type: Object, required: true },
committed: { type: Object, default: null }, /* Set or null */
},
components: { Chevron, FolderIcon, FileIcon },
setup(props) {
const pad = computed(() => 10 + props.depth * 13);
const isOpen = computed(() => props.node.type === "dir" && (props.openDirs.has(props.node.path) || props.node.path === ""));
const status = computed(() => {
if (props.node.type === "dir") return null;
return (props.committed && props.committed.has(props.node.path)) ? null : props.changeMap[props.node.path];
});
return { pad, isOpen, status };
},
/* Multi-root fragment for the dir case */
template: `
<template v-if="node.type === 'dir'">
<div
v-if="node.path !== ''"
class="tree-row folder"
:style="{ paddingLeft: pad + 'px' }"
@click="toggleDir(node.path)"
@contextmenu="(e) => onContext(e, { path: node.path, kind: 'dir' })">
<span class="tw"><Chevron :open="isOpen" /></span>
<FolderIcon :open="isOpen" />
<span class="tree-label">{{ node.name }}</span>
</div>
<template v-if="isOpen">
<TreeNode
v-for="c in node.children"
: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" />
</template>
</template>
<div
v-else
:class="'tree-row' + (activePath === node.path ? ' active' : '')"
:style="{ paddingLeft: (pad + 2) + 'px' }"
@click="onOpen(node.path)"
@contextmenu="(e) => onContext(e, { path: node.path, kind: 'file' })"
:title="node.path">
<span class="tw"></span>
<FileIcon :path="node.path" />
<span
class="tree-label"
:style="status === 'D' ? { textDecoration: 'line-through', color: 'var(--fg-3)' } : null">
{{ node.name }}
</span>
<span v-if="status" :class="'tree-badge ' + status">{{ status }}</span>
</div>
`,
};
/* Self-reference: register TreeNode in itself for recursion */
TreeNode.components = Object.assign(TreeNode.components || {}, { TreeNode });
/* ---- FileTree (multi-root fragment) ---- */
const FileTree = {
props: {
tree: { type: Object, required: true },
openDirs: { type: Object, required: true }, /* Set */
toggleDir: { type: Function, required: true },
onOpen: { type: Function, required: true },
onContext: { type: Function, required: true },
activePath: { type: String, default: null },
changeMap: { type: Object, required: true },
committed: { type: Object, default: null }, /* Set or null */
},
components: { TreeNode },
template: `
<div class="phead">
<span>Explorer</span>
<span :style="{ marginLeft: 'auto', color: 'var(--fg-3)', textTransform: 'none', letterSpacing: 0, fontFamily: 'var(--mono)', fontSize: '10.5px' }">{{ tree.name }}</span>
</div>
<div class="tree-body">
<TreeNode
:node="tree"
:depth="0"
:openDirs="openDirs"
:toggleDir="toggleDir"
:onOpen="onOpen"
:onContext="onContext"
:activePath="activePath"
:changeMap="changeMap"
:committed="committed" />
</div>
`,
};
/* ---- Global export ---- */
window.HelderComponents = Object.assign(window.HelderComponents || {}, {
Icon,
Chevron,
FolderIcon,
FileIcon,
GitRow,
GitPanel,
TreeNode,
FileTree,
});

View File

@@ -1,331 +0,0 @@
/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */
const { ref, computed, onMounted, nextTick, watch } = Vue;
/* ---------- framework-agnostic helpers (ported verbatim) ---------- */
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 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" },
];
/* ---------- EditorTabs ---------- */
const EditorTabs = {
props: ["tabs", "active", "onActivate", "onClose"],
setup(props) {
const rootRef = ref(null);
watch(() => props.active, async () => {
await nextTick();
const el = rootRef.value && rootRef.value.querySelector(".tab.active");
if (el) el.scrollIntoView({ block: "nearest", inline: "nearest" });
});
return { rootRef };
},
template: `
<div class="tabs" ref="rootRef">
<div
v-for="t in tabs"
:key="t.path"
:class="'tab' + (active === t.path ? ' active' : '')"
@click="onActivate(t.path)"
@auxclick="(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path); } }"
:title="t.path"
>
<FileIcon :path="t.path" />
<span class="tname">{{ t.path.split('/').pop() }}</span>
<span v-if="t.changed" class="tab-mode">{{ t.modeLabel }}</span>
<span class="tclose" @click.stop="onClose(t.path)">
<Icon name="close" />
</span>
</div>
</div>
`,
};
/* ---------- PaneView ---------- */
const PaneView = {
props: ["cacheKey", "path", "lines", "lang", "showSign", "refLine", "cursor", "selection", "setCursor", "setSelection", "onContext"],
setup(props) {
// anchor for shift-click range selection
const anchorRef = ref(null);
// highlighted HTML — recomputed when cacheKey changes
const html = computed(() => props.lines.map((l) => HL.hlLine(l.text, props.lang)));
function gutterClick(e, no) {
if (no == null) return;
e.stopPropagation();
if (e.shiftKey && anchorRef.value != null) {
const a = anchorRef.value;
props.setSelection({ path: props.path, start: Math.min(a, no), end: Math.max(a, no), anchor: a });
} else {
anchorRef.value = no;
props.setSelection({ path: props.path, start: no, end: no, anchor: no });
}
props.setCursor({ path: props.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) { props.setSelection({ path: props.path, start: s, end: e, anchor: an }); props.setCursor({ path: props.path, line: fn, col: caretCol(sel) }); return; }
}
}
if (sel && sel.focusNode) {
const el = climbToLine(sel.focusNode);
if (el) { props.setCursor({ path: props.path, line: +el.dataset.line, col: caretCol(sel) }); props.setSelection(null); }
}
}
function handleContext(e) {
e.preventDefault();
const sel = window.getSelection();
let info = { path: props.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 (props.selection && props.selection.path === props.path && props.selection.start !== props.selection.end) {
info.sel = { start: props.selection.start, end: props.selection.end }; info.line = props.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 || (props.cursor && props.cursor.path === props.path ? props.cursor.line : 1);
}
props.setCursor({ path: props.path, line: info.line, col: 1 });
props.onContext(e, info);
}
return { html, gutterClick, onMouseUp, handleContext };
},
template: `
<div :class="'editor' + (showSign ? ' diff' : '')" @mouseup="onMouseUp" @contextmenu="handleContext">
<template v-for="(l, i) in lines" :key="i">
<div
:data-line="l.no == null ? undefined : l.no"
:class="'ln-row'
+ (l.row === 'add' ? ' add' : l.row === 'del' ? ' del' : '')
+ (l.row === 'bar-add' ? ' bar-add' : l.row === 'bar-del' ? ' bar-del' : '')
+ (l.no === (cursor && cursor.path === path ? cursor.line : -1) && !(selection && selection.path === path && l.no != null && l.no >= selection.start && l.no <= selection.end) && !l.row ? ' cursor' : '')
+ (selection && selection.path === path && l.no != null && l.no >= selection.start && l.no <= selection.end ? ' selrange' : '')"
>
<span class="ln-gutter" @click="(e) => gutterClick(e, l.no)">{{ l.no == null ? '' : l.no }}</span>
<span v-if="showSign" class="ln-sign">{{ l.sign === ' ' || !l.sign ? '' : l.sign }}</span>
<span class="ln-code" v-html="html[i]" />
</div>
</template>
</div>
`,
};
/* ---------- Editor ---------- */
const Editor = {
props: ["tabs", "active", "mode", "setMode", "onActivate", "onClose", "onContext", "onSplit", "splitOpen", "cursor", "selection", "setCursor", "setSelection"],
setup(props) {
const tab = computed(() => props.tabs.find((t) => t.path === props.active));
const change = computed(() => tab.value ? PROJECT.changes.find((c) => c.path === tab.value.path) : null);
const diff = computed(() => tab.value ? PROJECT.diffs[tab.value.path] : null);
const lang = computed(() => tab.value ? HL.langFor(tab.value.path) : null);
const effMode = computed(() => change.value ? props.mode : "code");
const built = computed(() => {
if (!tab.value) return null;
if (change.value && diff.value) return buildLines(effMode.value, diff.value, PROJECT.files[tab.value.path]);
return buildLines("code", null, PROJECT.files[tab.value.path]);
});
const statusWord = computed(() => {
if (!change.value) return "";
return change.value.status === "A" ? "Added" : change.value.status === "D" ? "Deleted" : "Modified";
});
const activeSeg = computed(() => props.splitOpen ? "split" : effMode.value);
const emptyUpdated = computed(() => effMode.value === "updated" && built.value && built.value.lines.length === 0);
const emptyOriginal = computed(() => effMode.value === "original" && built.value && built.value.lines.length === 0);
return { tab, change, diff, lang, effMode, built, statusWord, activeSeg, emptyUpdated, emptyOriginal, SEGMENTS };
},
template: `
<EditorTabs :tabs="tabs" :active="active" :onActivate="onActivate" :onClose="onClose" />
<div v-if="!tab" class="empty-ed">
<div :style="{ opacity: 0.5 }"><Icon name="file" :w="30" :h="30" /></div>
<div class="big">No file open</div>
<div class="klist">
<div><span>Search files &amp; 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 v-else class="editor-wrap">
<div v-if="change" class="diff-bar">
<span :class="'git-stat ' + change.status" style="width:auto">{{ statusWord }}</span>
<span v-if="change.add > 0" class="a">+{{ change.add }}</span>
<span v-if="change.del > 0" class="d">{{ change.del }}</span>
<div class="seg">
<button
v-for="s in SEGMENTS"
:key="s.id"
:class="activeSeg === s.id ? 'on' : ''"
@click="setMode(s.id)"
>{{ s.label }}</button>
<button
:class="'split-btn' + (activeSeg === 'split' ? ' on' : '')"
@click="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" stroke-width="1.2"/><line x1="6" y1="1.5" x2="6" y2="10.5" stroke="currentColor" stroke-width="1.2"/></svg>
Split
</button>
</div>
</div>
<div v-if="emptyUpdated" class="empty-ed">
<div class="big" :style="{ color: 'var(--del)' }">No updated version</div>
<div :style="{ fontFamily: 'var(--mono)', fontSize: '12px', color: 'var(--fg-3)' }">This file was deleted in the change.</div>
</div>
<div v-else-if="emptyOriginal" class="empty-ed">
<div class="big" :style="{ color: 'var(--add)' }">No original version</div>
<div :style="{ fontFamily: 'var(--mono)', fontSize: '12px', color: 'var(--fg-3)' }">This file is new in the change.</div>
</div>
<PaneView
v-else
: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>
`,
};
/* ---------- SplitView ---------- */
const SplitView = {
props: ["path", "onClose", "onContext"],
setup(props) {
const diff = computed(() => PROJECT.diffs[props.path]);
const lang = computed(() => HL.langFor(props.path));
const change = computed(() => PROJECT.changes.find((c) => c.path === props.path));
const leftRef = ref(null);
const rightRef = ref(null);
const lock = ref(false);
const leftHtml = computed(() => diff.value.split.map((r) => r.l ? HL.hlLine(r.l.text, lang.value) : ""));
const rightHtml = computed(() => diff.value.split.map((r) => r.r ? HL.hlLine(r.r.text, lang.value) : ""));
function sync(from, to) {
if (lock.value) return; lock.value = true;
to.scrollTop = from.scrollTop;
to.scrollLeft = from.scrollLeft;
requestAnimationFrame(() => { lock.value = 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;
props.onContext(e, { path: props.path, kind: "editor", line: no || 1 });
}
return { diff, lang, change, leftRef, rightRef, sync, ctx, leftHtml, rightHtml };
},
template: `
<div class="split-overlay">
<div class="split-head">
<FileIcon :path="path" />
<span class="sh-name">{{ path }}</span>
<span v-if="change" :class="'git-stat ' + change.status" style="width:auto">{{ change.status === 'A' ? 'Added' : change.status === 'D' ? 'Deleted' : 'Modified' }}</span>
<span v-if="change && change.add > 0" class="a" :style="{ fontFamily: 'var(--mono)', color: 'var(--add)' }">+{{ change.add }}</span>
<span v-if="change && change.del > 0" class="d" :style="{ fontFamily: 'var(--mono)', color: 'var(--del)' }">{{ change.del }}</span>
<button class="split-exit" @click="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" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
Collapse <kbd>Esc</kbd>
</button>
</div>
<div class="split-body">
<div class="split-pane left">
<div class="split-label">Original <span>before</span></div>
<div class="editor" ref="leftRef" @scroll="sync(leftRef, rightRef)" @contextmenu="(e) => ctx(e, 'l')">
<div
v-for="(row, i) in diff.split"
:key="i"
:data-line="row.l ? row.l.no : undefined"
:class="'ln-row' + (row.l && row.l.mark === 'del' ? ' bar-del' : '') + (!row.l ? ' empty' : '')"
>
<span class="ln-gutter">{{ row.l ? row.l.no : '' }}</span>
<span class="ln-code" v-html="row.l ? leftHtml[i] : ''" />
</div>
</div>
</div>
<div class="split-pane right">
<div class="split-label">Updated <span>after</span></div>
<div class="editor" ref="rightRef" @scroll="sync(rightRef, leftRef)" @contextmenu="(e) => ctx(e, 'r')">
<div
v-for="(row, i) in diff.split"
:key="i"
:data-line="row.r ? row.r.no : undefined"
:class="'ln-row' + (row.r && row.r.mark === 'add' ? ' bar-add' : '') + (!row.r ? ' empty' : '')"
>
<span class="ln-gutter">{{ row.r ? row.r.no : '' }}</span>
<span class="ln-code" v-html="row.r ? rightHtml[i] : ''" />
</div>
</div>
</div>
</div>
</div>
`,
};
/* ---------- global registration ---------- */
window.HelderComponents = Object.assign(window.HelderComponents || {}, { EditorTabs, PaneView, Editor, SplitView });
window.buildLines = buildLines;
window.climbToLine = climbToLine;

View File

@@ -1,79 +0,0 @@
/* 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
}
// highlight a single line independently (keeps line numbering robust)
function hlLine(line, lang) {
if (line === "") return "&nbsp;";
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 };
})();

View File

@@ -1,332 +0,0 @@
/* Overlays: command palette (fuzzy file finder), content search, context menu, toast */
const { ref, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
// ---------------------------------------------------------------------------
// fuzzy(q, str) — verbatim port
// ---------------------------------------------------------------------------
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;
}
// ---------------------------------------------------------------------------
// Highlight — props: text, idx
// ---------------------------------------------------------------------------
const Highlight = {
props: ['text', 'idx'],
setup(props) {
// Build an array of { ch, bold } for rendering
const parts = computed(() => {
const set = props.idx && props.idx.length ? new Set(props.idx) : null;
return props.text.split('').map((ch, i) => ({ ch, bold: set ? set.has(i) : false }));
});
return { parts };
},
template: `
<span>
<template v-for="(p, i) in parts" :key="i">
<b v-if="p.bold">{{ p.ch }}</b>
<template v-else>{{ p.ch }}</template>
</template>
</span>
`
};
// ---------------------------------------------------------------------------
// SearchModal — props: onOpen, onOpenAt, onClose, changeSet
// ---------------------------------------------------------------------------
const SearchModal = {
props: ['onOpen', 'onOpenAt', 'onClose', 'changeSet'],
setup(props) {
const q = ref('');
const sel = ref(0);
const inputEl = ref(null);
const leftEl = ref(null);
// All paths — stable, PROJECT.files doesn't change at runtime
const allPaths = computed(() => Object.keys(PROJECT.files));
// Content hits (left panel) — substring search, min 2 chars
const content = computed(() => {
const term = q.value.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;
});
// File-name fuzzy matches (right panel)
const files = computed(() => {
const term = q.value.trim();
if (!term) return [];
const out = [];
for (const p of allPaths.value) {
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;
});
// Flat list of content hits for keyboard nav + flat-index tracking
// Each entry gets its flatIndex here so the template can use it directly
const flat = computed(() => {
const arr = [];
content.value.forEach((g) => g.hits.forEach((h) => arr.push({ path: g.path, no: h.no })));
return arr;
});
// Precomputed flat-index map: "path:lineNo" -> flatIndex
// Used in template to assign .sel class without mutating during render
const flatIndexMap = computed(() => {
const map = {};
let ix = 0;
content.value.forEach((g) => {
g.hits.slice(0, 12).forEach((h) => {
map[`${g.path}:${h.no}`] = ix++;
});
});
return map;
});
const totalHits = computed(() => flat.value.length);
// Reset selection when query changes
watch(q, () => { sel.value = 0; });
// Scroll selected line into view when sel changes
watch(sel, () => {
nextTick(() => {
const el = leftEl.value && leftEl.value.querySelector('.sr-line.sel');
if (el) el.scrollIntoView({ block: 'nearest' });
});
});
onMounted(() => { inputEl.value && inputEl.value.focus(); });
function onKey(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
sel.value = Math.min(sel.value + 1, flat.value.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
sel.value = Math.max(sel.value - 1, 0);
} else if (e.key === 'Enter') {
e.preventDefault();
if (flat.value[sel.value]) {
props.onOpenAt(flat.value[sel.value].path, flat.value[sel.value].no);
props.onClose();
} else if (files.value[0]) {
props.onOpen(files.value[0].path);
props.onClose();
}
} else if (e.key === 'Escape') {
e.preventDefault();
props.onClose();
}
}
// renderLine: returns { pre, mid, post } for a content line hit
function renderLine(ln, ix, len) {
return { pre: ln.slice(0, ix), mid: ln.slice(ix, ix + len), post: ln.slice(ix + len) };
}
return {
q, sel, inputEl, leftEl,
content, files, flat, flatIndexMap, totalHits,
onKey, renderLine,
};
},
template: `
<div class="scrim" @mousedown="onClose">
<div class="search-modal" @mousedown.stop>
<div class="pi">
<Icon name="search" :style="{ color: 'var(--fg-3)' }" />
<input ref="inputEl" :value="q" @input="q = $event.target.value" @keydown="onKey"
placeholder="Search content and file names…" :spellcheck="false" />
<span class="mode-chip">{{ totalHits }} hit{{ totalHits === 1 ? '' : 's' }} · {{ files.length }} file{{ files.length === 1 ? '' : 's' }}</span>
</div>
<div class="search-cols">
<div class="sc-left" ref="leftEl">
<div class="sc-head">Content <span v-if="totalHits > 0" class="sc-ct">{{ totalHits }}</span></div>
<div v-if="q.trim().length < 2" class="pempty">Type at least 2 characters</div>
<div v-else-if="content.length === 0" class="pempty">No content matches</div>
<template v-for="g in content" :key="g.path">
<div class="sr-file" @click="onOpenAt(g.path, g.hits[0].no)">
<FileIcon :path="g.path" />
<span class="srf-name">{{ g.path }}</span>
<span class="cnt">{{ g.hits.length }}</span>
</div>
<div v-for="h in g.hits.slice(0, 12)" :key="h.no"
:class="'sr-line' + (flatIndexMap[g.path + ':' + h.no] === sel ? ' sel' : '')"
@mouseenter="sel = flatIndexMap[g.path + ':' + h.no]"
@click="onOpenAt(g.path, h.no); onClose()">
<span class="no">{{ h.no }}</span>
<span class="tx">{{ renderLine(h.ln, h.ix, q.trim().length).pre }}<mark>{{ renderLine(h.ln, h.ix, q.trim().length).mid }}</mark>{{ renderLine(h.ln, h.ix, q.trim().length).post }}</span>
</div>
</template>
</div>
<div class="sc-right">
<div class="sc-head">Files <span v-if="files.length > 0" class="sc-ct">{{ files.length }}</span></div>
<div v-if="!q.trim()" class="pempty sm">Start typing…</div>
<div v-else-if="files.length === 0" class="pempty sm">No file names match</div>
<div v-for="r in files.slice(0, 40)" :key="r.path" class="fres"
@click="onOpen(r.path); onClose()" :title="r.path">
<FileIcon :path="r.path" />
<div class="fres-txt">
<span class="fn"><Highlight :text="r.path.split('/').pop()" :idx="r.idx" /></span>
<span v-if="r.path.split('/').slice(0,-1).join('/')" class="fd">{{ r.path.split('/').slice(0,-1).join('/') }}/</span>
</div>
<span v-if="changeSet.has(r.path)" class="tree-badge M" :style="{ fontFamily: 'var(--mono)', fontSize: '10px' }">•</span>
</div>
</div>
</div>
</div>
</div>
`
};
// ---------------------------------------------------------------------------
// ContextMenu — props: menu, onClose
// ---------------------------------------------------------------------------
const ContextMenu = {
props: ['menu', 'onClose'],
setup(props) {
const menuEl = ref(null);
function outsideClick(e) {
if (menuEl.value && !menuEl.value.contains(e.target)) props.onClose();
}
function escKey(e) {
if (e.key === 'Escape') props.onClose();
}
onMounted(() => {
document.addEventListener('mousedown', outsideClick);
document.addEventListener('keydown', escKey);
});
onUnmounted(() => {
document.removeEventListener('mousedown', outsideClick);
document.removeEventListener('keydown', escKey);
});
const pos = computed(() => {
if (!props.menu) return { x: 0, y: 0 };
return {
x: Math.min(props.menu.x, window.innerWidth - 270),
y: Math.min(props.menu.y, window.innerHeight - (props.menu.items.length * 34 + 60)),
};
});
return { menuEl, pos };
},
template: `
<div v-if="menu" class="ctx" ref="menuEl" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<div v-if="menu.note" class="ctx-note">{{ menu.note }}</div>
<template v-for="(it, i) in menu.items" :key="i">
<div v-if="it.sep" class="ctx-sep" />
<div v-else :class="'ctx-item' + (it.primary ? ' primary' : '')"
@click="it.onClick(); onClose()">
<span class="ic"><Icon :name="it.icon" /></span>
<span>{{ it.label }}</span>
<span v-if="it.kbd" class="kc">{{ it.kbd }}</span>
</div>
</template>
</div>
`
};
// ---------------------------------------------------------------------------
// Toasts — props: toasts
// ---------------------------------------------------------------------------
const Toasts = {
props: ['toasts'],
template: `
<div class="toast-wrap">
<div v-for="t in toasts" :key="t.id" class="toast">
<Icon name="copy" :style="{ color: 'var(--accent)' }" />
<span class="tt">{{ t.title }}</span>
<span v-if="t.ref" class="tref">{{ t.ref }}</span>
</div>
</div>
`
};
// ---------------------------------------------------------------------------
// PassPopup — props: x, y, refStr, onConfirm, onCancel
// ---------------------------------------------------------------------------
const PassPopup = {
props: ['x', 'y', 'refStr', 'onConfirm', 'onCancel'],
setup(props) {
const text = ref('');
const inputEl = ref(null);
const boxEl = ref(null);
onMounted(() => { inputEl.value && inputEl.value.focus(); });
function outsideClick(e) {
if (boxEl.value && !boxEl.value.contains(e.target)) props.onCancel();
}
// Capture phase — matches original's addEventListener(…, true)
function escKey(e) {
if (e.key === 'Escape') { e.preventDefault(); props.onCancel(); }
}
onMounted(() => {
document.addEventListener('mousedown', outsideClick);
document.addEventListener('keydown', escKey, true);
});
onUnmounted(() => {
document.removeEventListener('mousedown', outsideClick);
document.removeEventListener('keydown', escKey, true);
});
const left = computed(() => Math.min(props.x, window.innerWidth - 360));
const top = computed(() => Math.min(props.y + 6, window.innerHeight - 150));
const preview = computed(() => (text.value.trim() ? text.value.trim() + ' ' : '') + props.refStr);
function onKeyDown(e) {
if (e.key === 'Enter') { e.preventDefault(); props.onConfirm(text.value); }
else if (e.key === 'Escape') { e.preventDefault(); props.onCancel(); }
}
return { text, inputEl, boxEl, left, top, preview, onKeyDown };
},
template: `
<div class="pass-pop" ref="boxEl" :style="{ left: left + 'px', top: top + 'px' }">
<div class="pass-head"><Icon name="spark" /><span>Pass on to Agent</span><span class="pass-esc">esc</span></div>
<input ref="inputEl" class="pass-input" :value="text" :spellcheck="false"
placeholder="Add a note (optional)…"
@input="text = $event.target.value"
@keydown="onKeyDown" />
<div class="pass-preview"><span class="pp-lbl">inserts</span><code>{{ preview }}</code></div>
<div class="pass-foot"><kbd>↵</kbd> insert into agent · <kbd>esc</kbd> cancel</div>
</div>
`
};
// ---------------------------------------------------------------------------
// Expose
// ---------------------------------------------------------------------------
window.HelderComponents = Object.assign(window.HelderComponents || {}, {
Highlight, SearchModal, ContextMenu, Toasts, PassPopup,
});
window.fuzzy = fuzzy;

View File

@@ -1,261 +0,0 @@
/* Terminals: a generic shell that boots an (original-styled) AI agent session via `claude`. */
const { ref, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
let _lid = 0;
const lid = () => ++_lid;
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">&gt;</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>`,
})),
],
};
}
const Terminal = {
props: ["kind", "seed"],
setup(props) {
const lines = ref(props.seed.lines);
const mode = ref(props.seed.mode);
const input = ref("");
const busy = ref(false);
const bodyRef = ref(null);
const inputRef = ref(null);
const hist = ref([]);
const histIx = ref(-1);
// Auto-scroll on lines/busy change
watch([lines, busy], () => nextTick(() => {
const el = bodyRef.value;
if (el) el.scrollTop = el.scrollHeight;
}));
const push = (line) => { lines.value = [...lines.value, { id: lid(), ...line }]; };
const pushMany = (arr) => { lines.value = [...lines.value, ...arr.map((l) => ({ id: lid(), ...l }))]; };
async function runAgent(prompt) {
busy.value = true;
push({ kind: "agent-think", html: `<span class="ag">●</span> <span class="dim">thinking…</span>` });
await wait(520);
lines.value = lines.value.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>.` });
busy.value = false;
requestAnimationFrame(() => inputRef.value && inputRef.value.focus());
}
function shell(cmd) {
const [c, ...rest] = cmd.split(/\s+/);
const arg = rest.join(" ");
switch (c) {
case "": return;
case "claude":
pushMany(bootAgent());
mode.value = "agent";
return;
case "clear": lines.value = []; return;
case "help":
pushMany([
{ kind: "t", cls: "dim", html: "commands: <span style='color:var(--fg-1)'>claude</span> ls pwd cat &lt;file&gt; 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) || "&nbsp;" })));
return;
}
default: push({ kind: "t", cls: "err", html: `${HL.escapeHtml(c)}: command not found` });
}
}
function submit() {
const cmd = input.value.trim();
if (busy.value) return;
if (cmd) { hist.value.unshift(cmd); }
histIx.value = -1;
if (mode.value === "agent") {
if (cmd === "/exit" || cmd === "exit") {
push({ kind: "t", cls: "dim", html: "<span class='ag'>●</span> Session ended." });
mode.value = "shell"; input.value = ""; return;
}
if (cmd === "/clear" || cmd === "clear") { lines.value = []; input.value = ""; return; }
push({ kind: "t", html: `<span class="ip ag">&gt;</span> ${HL.escapeHtml(cmd).replace(/\n/g, "<br>&nbsp;&nbsp;") || "&nbsp;"}` });
input.value = "";
if (cmd) runAgent(cmd);
return;
}
push({ kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> ${HL.escapeHtml(cmd) || "&nbsp;"}` });
input.value = "";
shell(cmd);
}
function onKey(e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); return; }
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !input.value.includes("\n")) {
e.preventDefault();
if (e.key === "ArrowUp") {
if (hist.value.length) {
histIx.value = Math.min(histIx.value + 1, hist.value.length - 1);
input.value = hist.value[histIx.value];
}
} else {
if (histIx.value > 0) { histIx.value--; input.value = hist.value[histIx.value]; }
else { histIx.value = -1; input.value = ""; }
}
}
}
// agentPaste CustomEvent listener — agent kind only
let agentPasteHandler = null;
onMounted(() => {
if (props.kind !== "agent") return;
agentPasteHandler = (e) => {
if (mode.value !== "agent") { pushMany(bootAgent()); mode.value = "agent"; }
// bracketed-paste semantics: append as a new, UNSUBMITTED line; leave caret on a fresh line
const base = input.value.replace(/\n+$/, "");
input.value = (base ? base + "\n" : "") + e.detail + "\n";
requestAnimationFrame(() => {
const el = inputRef.value;
if (el) { el.focus(); const v = el.value.length; el.setSelectionRange(v, v); }
});
};
window.addEventListener("agentPaste", agentPasteHandler);
});
onUnmounted(() => {
if (agentPasteHandler) window.removeEventListener("agentPaste", agentPasteHandler);
});
const live = computed(() => mode.value === "agent");
const inputRows = computed(() => Math.min(8, Math.max(1, input.value.split("\n").length)));
const inputPlaceholder = computed(() =>
live.value ? "Ask the agent to change something…" : props.kind === "agent" ? "type 'claude' to start a session" : ""
);
return { lines, mode, input, busy, bodyRef, inputRef, live, inputRows, inputPlaceholder, onKey, submit };
},
template: `
<div class="term-pane" :style="{ flex: 1, minHeight: 0 }" @mousedown="inputRef && inputRef.focus()">
<div class="term-head">
<span :class="'dot' + (live ? ' live' : '')"></span>
<span class="lbl">{{ live ? 'claude' : kind === 'agent' ? 'claude' : 'zsh' }}</span>
<span class="tag">{{ live ? 'agent session' : '— bash · ~/console' }}</span>
</div>
<div class="term-body" ref="bodyRef">
<template v-for="l in lines" :key="l.id">
<div v-if="l.kind === 'card'" class="term-card">
<div class="ch"><Icon name="diff" :w="11" :h="11" /><span>{{ l.ch }}</span></div>
<div v-for="(r, i) in l.rows" :key="i" :class="r.cls">{{ r.t }}</div>
</div>
<div v-else-if="l.kind === 'welcome'" class="term-card" :style="{ borderColor: 'var(--accent-line)' }">
<div :style="{ color: 'var(--accent)', fontWeight: 600 }">◇ agent session</div>
<div class="dim" :style="{ color: 'var(--fg-2)', marginTop: '3px' }">{{ l.text }}</div>
</div>
<div v-else :class="'tline ' + (l.cls || '')" v-html="l.html"></div>
</template>
<div v-if="busy" class="tline dim"><span class="ag" :style="{ color: '#c98bdb' }">●</span> working<span class="cursor-blink" /></div>
<div v-if="!busy" class="term-input">
<span :class="'ip' + (live ? ' ag' : '')">
<template v-if="live">&gt;</template>
<template v-else><span class="pfx">console <span :style="{ color: 'var(--fg-3)' }">%</span></span></template>
</span>
<textarea
ref="inputRef"
class="term-ta"
v-model="input"
:spellcheck="false"
autocomplete="off"
:rows="inputRows"
:placeholder="inputPlaceholder"
@keydown="onKey"
></textarea>
</div>
</div>
</div>
`,
};
// Expose globals for app.js and other files
window.wait = wait;
window.lid = lid;
window.bootAgent = bootAgent;
window.agentSeed = agentSeed;
window.shellSeed = shellSeed;
window.HelderComponents = Object.assign(window.HelderComponents || {}, { Terminal });

24
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { resolve } from 'node:path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: { outDir: 'out/main' },
},
preload: {
plugins: [externalizeDepsPlugin()],
build: { outDir: 'out/preload' },
},
renderer: {
root: 'src/renderer',
build: {
outDir: 'out/renderer',
rollupOptions: {
input: resolve(__dirname, 'src/renderer/index.html'),
},
},
plugins: [react()],
},
})

3357
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "helder",
"version": "0.1.0",
"description": "Helder — dark-only Electron code workbench for reviewing AI-written code",
"private": true,
"type": "module",
"main": "./out/main/index.js",
"author": "Jonathan van Rij",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"start": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.web.json && tsc --noEmit -p tsconfig.node.json",
"rebuild": "electron-rebuild -f -w node-pty",
"postinstall": "electron-rebuild -f -w node-pty"
},
"dependencies": {
"@fontsource/jetbrains-mono": "^5.0.20",
"@vscode/ripgrep": "^1.18.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"chokidar": "^5.0.0",
"node-pty": "^1.1.0",
"prismjs": "^1.29.0",
"simple-git": "^3.36.0"
},
"devDependencies": {
"@electron/rebuild": "^4.0.4",
"@types/prismjs": "^1.26.4",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"electron": "^31.3.0",
"electron-vite": "^2.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.5.4",
"vite": "^5.3.5"
}
}

93
src/main/config.ts Normal file
View File

@@ -0,0 +1,93 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
/**
* Project-scoped settings, living in `.helder/` in the opened project's root.
* - config.default.json full built-in defaults, REGENERATED on every launch
* (live documentation; the app never reads user edits here)
* - config.json sparse — only user-overridden values
* - theme.css custom CSS over the built-in dark theme; the CODE FONT
* and FONT SIZE live here (as CSS vars), not in the JSON
* Effective value = config.json over config.default.json, merged key by key.
*/
export interface HelderConfig {
ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number }
git: { confirmDiscard: boolean }
terminal: { shell: string | null }
}
export const DEFAULTS: HelderConfig = {
ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 },
git: { confirmDiscard: true },
terminal: { shell: null },
}
const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in dark theme.
* This file is created once and never overwritten; edit it freely.
* The code font and font size live here (not in config.json). Uncomment and
* tweak any variable below; you can also override any --token from the built-in
* theme (see the design tokens in the app's styles). */
:root {
/* Code surfaces (editor + terminals) */
/* --code-font: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
/* --code-size: 13px; */ /* editor font size */
/* --term-size: 12.5px; */ /* terminal font size */
/* Example accent override: */
/* --accent: #4d8dff; */
}
`
let current: HelderConfig = DEFAULTS
let themeCss = ''
function isPlainObject(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === 'object' && !Array.isArray(v)
}
function deepMerge<T>(base: T, over: unknown): T {
if (!isPlainObject(base) || !isPlainObject(over)) return base
const out: Record<string, unknown> = { ...base }
for (const key of Object.keys(over)) {
const b = (base as Record<string, unknown>)[key]
const o = over[key]
if (isPlainObject(b) && isPlainObject(o)) out[key] = deepMerge(b, o)
else if (o !== undefined) out[key] = o
}
return out as T
}
/** (Re)resolve config + theme for a project root, regenerating the defaults file. */
export async function resolveConfig(root: string): Promise<void> {
const dir = join(root, '.helder')
try {
await mkdir(dir, { recursive: true })
// Always regenerate the defaults file — it documents every setting.
await writeFile(join(dir, 'config.default.json'), JSON.stringify(DEFAULTS, null, 2) + '\n')
let override: unknown = {}
try { override = JSON.parse(await readFile(join(dir, 'config.json'), 'utf8')) } catch { /* none / invalid */ }
current = deepMerge(DEFAULTS, override)
try {
themeCss = await readFile(join(dir, 'theme.css'), 'utf8')
} catch {
themeCss = THEME_TEMPLATE
await writeFile(join(dir, 'theme.css'), THEME_TEMPLATE)
}
} catch {
// Read-only / inaccessible root: fall back to built-in defaults.
current = DEFAULTS
themeCss = ''
}
}
export function getConfig(): HelderConfig {
return current
}
export function getThemeCss(): string {
return themeCss
}

117
src/main/fs-service.ts Normal file
View File

@@ -0,0 +1,117 @@
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
export interface FileNode {
name: string
type: 'dir' | 'file'
path: string
open?: boolean
children?: FileNode[]
}
/** Directories never walked — noise or huge, and not part of "the project". */
const IGNORE_DIRS = new Set([
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
])
const MAX_FILE_BYTES = 300_000
const MAX_INDEXED_FILES = 6000
function ignored(name: string): boolean {
return IGNORE_DIRS.has(name) || name === '.DS_Store'
}
/** Recursive project tree, dirs first then files, alphabetical. */
export async function readTree(root: string): Promise<FileNode> {
const name = root.split(sep).filter(Boolean).pop() || root
return { name, type: 'dir', path: '', open: true, children: await readDir(root, root, 0) }
}
async function readDir(abs: string, root: string, depth: number): Promise<FileNode[]> {
let entries: import('node:fs').Dirent[]
try {
entries = await readdir(abs, { withFileTypes: true })
} catch {
return []
}
const dirs: FileNode[] = []
const files: FileNode[] = []
for (const e of entries) {
if (ignored(e.name)) continue
const childAbs = join(abs, e.name)
const rel = relative(root, childAbs).split(sep).join('/')
if (e.isDirectory()) {
dirs.push({
name: e.name, type: 'dir', path: rel, open: depth < 1,
children: depth < 12 ? await readDir(childAbs, root, depth + 1) : [],
})
} else if (e.isFile()) {
files.push({ name: e.name, type: 'file', path: rel })
}
}
dirs.sort((a, b) => a.name.localeCompare(b.name))
files.sort((a, b) => a.name.localeCompare(b.name))
return [...dirs, ...files]
}
function looksBinary(buf: Buffer): boolean {
const n = Math.min(buf.length, 8000)
for (let i = 0; i < n; i++) if (buf[i] === 0) return true
return false
}
/** Read a single text file (relative path) → string. */
export async function readProjectFile(root: string, rel: string): Promise<string> {
const buf = await readFile(join(root, rel))
if (looksBinary(buf)) return ''
return buf.toString('utf8')
}
/** Write a text file (relative path). Used by the editable buffer's save. */
export async function writeProjectFile(root: string, rel: string, content: string): Promise<void> {
await writeFile(join(root, rel), content, 'utf8')
}
/**
* Build an in-memory content index of all (small, text) files — powers content
* search and plain-file viewing without touching disk per keystroke. Capped to
* keep large repos sane. PHASE: swap content search to ripgrep when scaling up.
*/
export async function readAll(root: string): Promise<Record<string, string>> {
const out: Record<string, string> = {}
let count = 0
async function walk(abs: string): Promise<void> {
if (count >= MAX_INDEXED_FILES) return
let entries: import('node:fs').Dirent[]
try {
entries = await readdir(abs, { withFileTypes: true })
} catch {
return
}
for (const e of entries) {
if (count >= MAX_INDEXED_FILES) return
if (ignored(e.name)) continue
const childAbs = join(abs, e.name)
if (e.isDirectory()) {
await walk(childAbs)
} else if (e.isFile()) {
try {
const s = await stat(childAbs)
if (s.size > MAX_FILE_BYTES) continue
const buf = await readFile(childAbs)
if (looksBinary(buf)) continue
const rel = relative(root, childAbs).split(sep).join('/')
out[rel] = buf.toString('utf8')
count++
} catch {
/* skip unreadable */
}
}
}
}
await walk(root)
return out
}

108
src/main/git-service.ts Normal file
View File

@@ -0,0 +1,108 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { simpleGit, type SimpleGit } from 'simple-git'
export type GitStatusLetter = 'A' | 'M' | 'D' | 'R' | 'U'
export interface GitChange {
path: string
status: GitStatusLetter
staged: boolean
original: string
updated: string
}
export interface GitLoad {
branch: string
changes: GitChange[]
}
function git(root: string): SimpleGit {
return simpleGit({ baseDir: root, maxConcurrentProcesses: 4 })
}
/** Map a porcelain code pair to our display letter + staged flag. */
function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } {
const staged = index !== ' ' && index !== '?'
const code = staged ? index : working
let letter: GitStatusLetter
switch (code) {
case 'A': case 'C': case '?': letter = 'A'; break
case 'D': letter = 'D'; break
case 'R': letter = 'R'; break
case 'U': letter = 'M'; break
case 'M': default: letter = 'M'; break
}
return { letter, staged }
}
async function headText(g: SimpleGit, path: string): Promise<string> {
try {
return await g.show([`HEAD:${path}`])
} catch {
return ''
}
}
async function diskText(root: string, path: string): Promise<string> {
try {
const buf = await readFile(join(root, path))
// skip obvious binaries
for (let i = 0; i < Math.min(buf.length, 8000); i++) if (buf[i] === 0) return ''
return buf.toString('utf8')
} catch {
return ''
}
}
export async function isRepo(root: string): Promise<boolean> {
try {
return await git(root).checkIsRepo()
} catch {
return false
}
}
export async function load(root: string): Promise<GitLoad | null> {
const g = git(root)
if (!(await isRepo(root))) return null
const status = await g.status()
const branch = status.current || 'HEAD'
const changes: GitChange[] = []
for (const f of status.files) {
// simple-git uses path "from -> to" for renames; take the destination.
const path = f.path.includes(' -> ') ? f.path.split(' -> ').pop()! : f.path
const { letter, staged } = classify(f.index, f.working_dir)
const isNew = f.index === '?' || f.index === 'A'
const isDeleted = letter === 'D'
const original = isNew ? '' : await headText(g, path)
const updated = isDeleted ? '' : await diskText(root, path)
changes.push({ path, status: letter, staged, original, updated })
}
return { branch, changes }
}
export async function stage(root: string, paths: string[]): Promise<void> {
// `git add` stages modifications, additions AND deletions of the given paths.
await git(root).add(paths)
}
export async function unstage(root: string, paths: string[]): Promise<void> {
try {
await git(root).reset(['--', ...paths])
} catch {
// empty repo (no HEAD yet): fall back to removing from the index.
await git(root).raw(['rm', '--cached', '-r', '--', ...paths])
}
}
export async function commit(root: string, message: string): Promise<void> {
await git(root).commit(message)
}
export async function discard(root: string, paths: string[]): Promise<void> {
await git(root).checkout(['--', ...paths])
}

141
src/main/index.ts Normal file
View File

@@ -0,0 +1,141 @@
import { join, sep } from 'node:path'
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { watch, type FSWatcher } from 'chokidar'
import { getName, getRoot, openDialog } from './project'
import { readAll, readProjectFile, readTree, writeProjectFile } from './fs-service'
import { commit, discard, load, stage, unstage } from './git-service'
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
import { getConfig, getThemeCss, resolveConfig } from './config'
import { listFiles, searchContent } from './search-service'
const isDev = !!process.env['ELECTRON_RENDERER_URL']
const isMac = process.platform === 'darwin'
const WATCH_IGNORE = new Set([
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
])
let watcher: FSWatcher | null = null
let configWatcher: FSWatcher | null = null
let watchTimer: ReturnType<typeof setTimeout> | null = null
function broadcast(channel: string): void {
for (const w of BrowserWindow.getAllWindows()) w.webContents.send(channel)
}
function startConfigWatcher(): void {
if (configWatcher) { configWatcher.close(); configWatcher = null }
const root = getRoot()
if (!root) return
const dir = join(root, '.helder')
configWatcher = watch([join(dir, 'config.json'), join(dir, 'theme.css')], { ignoreInitial: true })
const reload = (): void => { resolveConfig(root).then(() => broadcast('config:changed')).catch(() => {}) }
configWatcher.on('add', reload).on('change', reload).on('unlink', reload)
}
function startWatcher(): void {
if (watcher) { watcher.close(); watcher = null }
const root = getRoot()
if (!root) return
watcher = watch(root, {
ignoreInitial: true,
ignored: (p: string) => p.split(sep).some((seg) => WATCH_IGNORE.has(seg)),
})
const ping = (): void => {
if (watchTimer) clearTimeout(watchTimer)
watchTimer = setTimeout(() => broadcast('project:changed'), 250)
}
watcher.on('add', ping).on('change', ping).on('unlink', ping).on('addDir', ping).on('unlinkDir', ping)
}
function registerIpc(): void {
ipcMain.handle('project:current', () => ({ root: getRoot(), name: getName() }))
ipcMain.handle('project:open', async (e) => {
const win = BrowserWindow.fromWebContents(e.sender)
const next = await openDialog(win)
if (next) {
await resolveConfig(getRoot())
startWatcher()
startConfigWatcher()
}
return { root: getRoot(), name: getName() }
})
ipcMain.handle('fs:tree', () => readTree(getRoot()))
ipcMain.handle('fs:files', () => readAll(getRoot()))
ipcMain.handle('fs:read', (_e, rel: string) => readProjectFile(getRoot(), rel))
ipcMain.handle('fs:write', (_e, rel: string, content: string) => writeProjectFile(getRoot(), rel, content))
ipcMain.handle('git:load', () => load(getRoot()))
ipcMain.handle('git:stage', (_e, paths: string[]) => stage(getRoot(), paths))
ipcMain.handle('git:unstage', (_e, paths: string[]) => unstage(getRoot(), paths))
ipcMain.handle('git:commit', (_e, message: string) => commit(getRoot(), message))
ipcMain.handle('git:discard', (_e, paths: string[]) => discard(getRoot(), paths))
ipcMain.handle('pty:available', () => ptyAvailable())
ipcMain.handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows))
ipcMain.on('pty:write', (_e, id: number, data: string) => writePty(id, data))
ipcMain.on('pty:resize', (_e, id: number, cols: number, rows: number) => resizePty(id, cols, rows))
ipcMain.on('pty:kill', (_e, id: number) => killPty(id))
ipcMain.handle('config:get', () => getConfig())
ipcMain.handle('config:theme', () => getThemeCss())
ipcMain.handle('search:content', (_e, query: string) => searchContent(getRoot(), query))
ipcMain.handle('search:files', () => listFiles(getRoot()))
}
function createWindow(): void {
const win = new BrowserWindow({
width: 1680,
height: 1040,
minWidth: 1100,
minHeight: 680,
show: false,
backgroundColor: '#16171a',
titleBarStyle: isMac ? 'hiddenInset' : 'default',
trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
})
win.on('ready-to-show', () => win.show())
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
if (isDev) {
win.loadURL(process.env['ELECTRON_RENDERER_URL'] as string)
} else {
win.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(async () => {
app.setName('Helder')
registerIpc()
await resolveConfig(getRoot())
startWatcher()
startConfigWatcher()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (watcher) { watcher.close(); watcher = null }
if (configWatcher) { configWatcher.close(); configWatcher = null }
killAllPtys()
if (!isMac) app.quit()
})
app.on('before-quit', () => killAllPtys())

45
src/main/project.ts Normal file
View File

@@ -0,0 +1,45 @@
import { basename } from 'node:path'
import { existsSync, statSync } from 'node:fs'
import { dialog, BrowserWindow } from 'electron'
/**
* One project per window. The root is resolved (in order) from $HELDER_PROJECT,
* a directory passed on argv, or the process working directory — then it can be
* changed at runtime via the Open Folder dialog.
*/
function resolveInitialRoot(): string {
const envRoot = process.env.HELDER_PROJECT
if (envRoot && existsSync(envRoot) && statSync(envRoot).isDirectory()) return envRoot
const argDir = process.argv.slice(1).find((a) => !a.startsWith('-') && existsSync(a) && safeIsDir(a))
if (argDir) return argDir
return process.cwd()
}
function safeIsDir(p: string): boolean {
try { return statSync(p).isDirectory() } catch { return false }
}
let root = resolveInitialRoot()
export function getRoot(): string {
return root
}
export function getName(): string {
return root ? basename(root) || root : 'no project'
}
export function setRoot(next: string): void {
root = next
}
export async function openDialog(win: BrowserWindow | null): Promise<string | null> {
const res = win
? await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
: await dialog.showOpenDialog({ properties: ['openDirectory'] })
if (!res.canceled && res.filePaths[0]) {
root = res.filePaths[0]
return root
}
return null
}

79
src/main/pty-service.ts Normal file
View File

@@ -0,0 +1,79 @@
import { createRequire } from 'node:module'
import type { WebContents } from 'electron'
import { getRoot } from './project'
import { getConfig } from './config'
/**
* Real PTYs. The agent pane is a shell that auto-launches the `claude` CLI; the
* bottom pane is a plain shell. node-pty is a native module — loaded defensively
* so the app still launches (with a friendly message) if it wasn't rebuilt for
* this Electron via `npm run rebuild`.
*
* Shell + ai command/autoLaunch come from `.helder/config.json` (terminal.shell,
* ai.command, ai.autoLaunch) via the config module.
*/
const require = createRequire(import.meta.url)
type PtyModule = typeof import('node-pty')
let pty: PtyModule | null = null
try {
pty = require('node-pty') as PtyModule
} catch (e) {
console.error('[helder] node-pty unavailable — run `npm run rebuild`:', (e as Error).message)
}
const terms = new Map<number, import('node-pty').IPty>()
let seq = 0
function defaultShell(): string {
const configured = getConfig().terminal.shell
if (configured) return configured
if (process.platform === 'win32') return process.env.COMSPEC || 'powershell.exe'
return process.env.SHELL || '/bin/zsh'
}
export function ptyAvailable(): boolean {
return !!pty
}
export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number {
if (!pty) return -1
const cwd = getRoot() || process.env.HOME || process.cwd()
const proc = pty.spawn(defaultShell(), [], {
name: 'xterm-color',
cols: cols || 80,
rows: rows || 24,
cwd,
env: process.env as { [key: string]: string },
})
const id = ++seq
terms.set(id, proc)
proc.onData((data) => { if (!sender.isDestroyed()) sender.send('pty:data', { id, data }) })
proc.onExit(() => { terms.delete(id); if (!sender.isDestroyed()) sender.send('pty:exit', { id }) })
const ai = getConfig().ai
if (kind === 'agent' && ai.autoLaunch) {
// small delay so the shell prompt is ready before we type the command
setTimeout(() => { try { proc.write(ai.command + '\r') } catch { /* exited */ } }, 350)
}
return id
}
export function writePty(id: number, data: string): void {
terms.get(id)?.write(data)
}
export function resizePty(id: number, cols: number, rows: number): void {
try { terms.get(id)?.resize(cols, rows) } catch { /* race with exit */ }
}
export function killPty(id: number): void {
const p = terms.get(id)
if (p) { try { p.kill() } catch { /* already gone */ } terms.delete(id) }
}
export function killAllPtys(): void {
for (const p of terms.values()) { try { p.kill() } catch { /* noop */ } }
terms.clear()
}

View File

@@ -0,0 +1,87 @@
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
import { relative, sep } from 'node:path'
/** Content search via ripgrep; file-name list via `rg --files`. Substring
* (fixed-string), smart-case — matching the prototype's search semantics. */
const require = createRequire(import.meta.url)
let rgPath: string | null = null
try {
rgPath = (require('@vscode/ripgrep') as { rgPath: string }).rgPath
} catch (e) {
console.error('[helder] @vscode/ripgrep unavailable:', (e as Error).message)
}
export interface ContentHit { no: number; ln: string; ix: number }
export interface ContentGroup { path: string; hits: ContentHit[] }
const IGNORE_GLOBS = ['node_modules', '.git', 'out', 'dist', 'build', '.cache', 'vendor', 'coverage', '.helder']
.flatMap((d) => ['--glob', `!${d}`])
const MAX_FILES = 400
const MAX_LINE = 1000
function toRel(root: string, p: string): string {
return relative(root, p).split(sep).join('/')
}
export function searchContent(root: string, query: string): Promise<ContentGroup[]> {
return new Promise((resolve) => {
if (!rgPath || query.trim().length < 2) return resolve([])
const child = spawn(rgPath, [
'--json', '--fixed-strings', '--smart-case',
'--max-count', '50', '--max-columns', '2000',
...IGNORE_GLOBS, '-e', query, '--', root,
])
const order: string[] = []
const groups = new Map<string, ContentGroup>()
let buf = ''
let done = false
const finish = (): void => { if (done) return; done = true; resolve(order.slice(0, MAX_FILES).map((p) => groups.get(p)!)) }
child.stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString()
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
if (!line) continue
let msg: { type: string; data: { path?: { text?: string }; lines?: { text?: string }; line_number?: number; submatches?: { start: number }[] } }
try { msg = JSON.parse(line) } catch { continue }
if (msg.type !== 'match') continue
const abs = msg.data.path?.text
const text = msg.data.lines?.text
if (!abs || text == null) continue
const rel = toRel(root, abs)
let g = groups.get(rel)
if (!g) { if (groups.size >= MAX_FILES) continue; g = { path: rel, hits: [] }; groups.set(rel, g); order.push(rel) }
const ln = text.replace(/\n$/, '').slice(0, MAX_LINE)
const ix = msg.data.submatches && msg.data.submatches[0] ? msg.data.submatches[0].start : 0
g.hits.push({ no: msg.data.line_number || 0, ln, ix: Math.min(ix, ln.length) })
}
})
child.on('close', finish)
child.on('error', finish)
})
}
export function listFiles(root: string): Promise<string[]> {
return new Promise((resolve) => {
if (!rgPath) return resolve([])
const child = spawn(rgPath, ['--files', ...IGNORE_GLOBS, '--', root])
let buf = ''
const out: string[] = []
let done = false
const finish = (): void => { if (done) return; done = true; resolve(out) }
child.stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString()
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
if (line) out.push(toRel(root, line))
}
})
child.on('close', () => { if (buf.trim()) out.push(toRel(root, buf.trim())); finish() })
child.on('error', finish)
})
}

9
src/preload/index.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import type { HelderApi } from './index'
declare global {
interface Window {
helder: HelderApi
}
}
export {}

85
src/preload/index.ts Normal file
View File

@@ -0,0 +1,85 @@
import { contextBridge, clipboard, ipcRenderer } from 'electron'
/**
* The single bridge between renderer and main. The renderer NEVER touches the
* filesystem, git or the OS clipboard directly — everything goes through here.
* (PTYs land here next, for the terminals.)
*/
const api = {
platform: process.platform,
clipboard: {
writeText: (text: string) => clipboard.writeText(text),
},
project: {
current: () => ipcRenderer.invoke('project:current'),
open: () => ipcRenderer.invoke('project:open'),
},
fs: {
tree: () => ipcRenderer.invoke('fs:tree'),
files: () => ipcRenderer.invoke('fs:files'),
read: (path: string) => ipcRenderer.invoke('fs:read', path),
write: (path: string, content: string): Promise<void> => ipcRenderer.invoke('fs:write', path, content),
},
git: {
load: () => ipcRenderer.invoke('git:load'),
stage: (paths: string[]) => ipcRenderer.invoke('git:stage', paths),
unstage: (paths: string[]) => ipcRenderer.invoke('git:unstage', paths),
commit: (message: string) => ipcRenderer.invoke('git:commit', message),
discard: (paths: string[]) => ipcRenderer.invoke('git:discard', paths),
},
pty: {
available: (): Promise<boolean> => ipcRenderer.invoke('pty:available'),
create: (kind: 'agent' | 'shell', cols: number, rows: number): Promise<number> =>
ipcRenderer.invoke('pty:create', kind, cols, rows),
write: (id: number, data: string): void => ipcRenderer.send('pty:write', id, data),
resize: (id: number, cols: number, rows: number): void => ipcRenderer.send('pty:resize', id, cols, rows),
kill: (id: number): void => ipcRenderer.send('pty:kill', id),
onData: (cb: (id: number, data: string) => void): (() => void) => {
const h = (_e: unknown, p: { id: number; data: string }): void => cb(p.id, p.data)
ipcRenderer.on('pty:data', h)
return () => ipcRenderer.removeListener('pty:data', h)
},
onExit: (cb: (id: number) => void): (() => void) => {
const h = (_e: unknown, p: { id: number }): void => cb(p.id)
ipcRenderer.on('pty:exit', h)
return () => ipcRenderer.removeListener('pty:exit', h)
},
},
config: {
get: () => ipcRenderer.invoke('config:get'),
theme: (): Promise<string> => ipcRenderer.invoke('config:theme'),
},
search: {
content: (query: string) => ipcRenderer.invoke('search:content', query),
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),
},
/** Subscribe to "the project changed on disk" pings. Returns an unsubscribe. */
onProjectChanged: (cb: () => void): (() => void) => {
const handler = (): void => cb()
ipcRenderer.on('project:changed', handler)
return () => ipcRenderer.removeListener('project:changed', handler)
},
/** Subscribe to .helder config/theme edits. Returns an unsubscribe. */
onConfigChanged: (cb: () => void): (() => void) => {
const handler = (): void => cb()
ipcRenderer.on('config:changed', handler)
return () => ipcRenderer.removeListener('config:changed', handler)
},
}
if (process.contextIsolated) {
contextBridge.exposeInMainWorld('helder', api)
} else {
;(globalThis as unknown as { helder: typeof api }).helder = api
}
export type HelderApi = typeof api

12
src/renderer/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Helder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

356
src/renderer/src/App.tsx Normal file
View File

@@ -0,0 +1,356 @@
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference, status bar */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { HL } from './highlight'
import { FileTree, GitPanel, Icon } from './components'
import type { ContextTarget } from './components'
import { Editor, SplitView } from './editor'
import type { Cursor, Mode, Selection } from './editor'
import { Terminal, lid } from './terminals'
import { ContextMenu, PassPopup, SearchModal, Toasts } from './overlays'
import type { Menu, Toast } from './overlays'
import type { FileNode, GitStatus } from './types'
import { useProject, useProjectActions } from './project'
const NO_COMMITTED = new Set<string>()
function Splitter({ orientation = 'v', onDelta }: { orientation?: 'v' | 'h'; onDelta: (dx: number, dy: number) => void }): React.ReactElement {
const [drag, setDrag] = useState(false)
function down(e: React.MouseEvent): void {
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: MouseEvent): void {
onDelta(ev.clientX - last.x, ev.clientY - last.y)
last = { x: ev.clientX, y: ev.clientY }
}
function up(): void {
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 }: { width: number }): React.ReactElement {
const [topFrac, setTopFrac] = useState(0.52)
const ref = useRef<HTMLDivElement>(null)
function delta(_dx: number, dy: number): void {
const h = ref.current ? ref.current.clientHeight : 600
setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h)))
}
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" />
</div>
<Splitter orientation="h" onDelta={delta} />
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
<Terminal kind="shell" />
</div>
</div>
</div>
)
}
function clamp(v: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, v)) }
function ancestors(path: string): string[] {
const parts = path.split('/'); const out: string[] = []
for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join('/'))
return out
}
function initialOpenDirs(node: FileNode, set: Set<string>): Set<string> {
if (node.type === 'dir') {
if (node.open && node.path) set.add(node.path)
;(node.children || []).forEach((c) => initialOpenDirs(c, set))
}
return set
}
export function App(): React.ReactElement {
const proj = useProject()
const actions = useProjectActions()
const changeMap = useMemo(() => Object.fromEntries(proj.changes.map((c) => [c.path, c.status])) as Record<string, GitStatus>, [proj.changes])
const changeSet = useMemo(() => new Set(proj.changes.map((c) => c.path)), [proj.changes])
const [tabs, setTabs] = useState<{ path: string }[]>([])
const [active, setActive] = useState<string | null>(null)
const [tabMode, setTabMode] = useState<Record<string, Mode>>({})
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set())
const [cursor, setCursor] = useState<Cursor | null>(null)
const [selection, setSelection] = useState<Selection | null>(null)
const [overlay, setOverlay] = useState<'search' | null>(null)
const [menu, setMenu] = useState<Menu | null>(null)
const [toasts, setToasts] = useState<Toast[]>([])
const [splitFor, setSplitFor] = useState<string | null>(null)
const [commitMsg, setCommitMsg] = useState('')
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string } | null>(null)
// Editable buffers: path → current text (absent = clean, showing on-disk content).
const [buffers, setBuffers] = useState<Record<string, string>>({})
const buffersRef = useRef(buffers); buffersRef.current = buffers
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
function diskText(path: string): string { return proj.files[path] ?? '' }
function bufferText(path: string | null): string { return path ? (buffers[path] ?? diskText(path)) : '' }
function isDirty(path: string): boolean { return buffers[path] != null && buffers[path] !== diskText(path) }
function writeToDisk(path: string, text: string): void {
if (window.helder) window.helder.fs.write(path, text).catch(() => toast('Save failed', path))
}
function saveActive(): void {
if (!active) return
const text = buffersRef.current[active]
if (text == null || text === diskText(active)) return
writeToDisk(active, text)
toast('Saved', active)
}
function onEdit(text: string): void {
if (!active) return
const path = active
setBuffers((b) => ({ ...b, [path]: text }))
if (proj.config.editor.autoSave) {
if (saveTimer.current) clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => writeToDisk(path, text), 600)
}
}
function doDiscard(path: string): void {
if (proj.config.git.confirmDiscard &&
!window.confirm(`Discard changes to ${path}?\nThis reverts the file to the last commit and cannot be undone.`)) return
actions.discard(path)
setBuffers((b) => { const n = { ...b }; delete n[path]; return n })
toast('Discarded changes', path)
}
const [gitW, setGitW] = useState(232)
const [treeW, setTreeW] = useState(244)
const [rightW, setRightW] = useState(444)
// Seed explorer expansion from the tree's `open` flags once per opened project.
const seededRoot = useRef<string | null | undefined>(undefined)
useEffect(() => {
if (proj.tree && seededRoot.current !== proj.root) {
seededRoot.current = proj.root
setOpenDirs(initialOpenDirs(proj.tree, new Set()))
}
}, [proj.tree, proj.root])
function toast(title: string, ref?: string): void {
const id = lid()
setToasts((t) => [...t, { id, title, ref }])
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2300)
}
async function copyText(text: string, label?: string): Promise<void> {
try {
if (window.helder && window.helder.clipboard) window.helder.clipboard.writeText(text)
else await navigator.clipboard.writeText(text)
} catch {
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 { /* noop */ } ta.remove()
}
toast(label || 'Copied reference', text)
}
const toggleDir = useCallback((p: string) => {
setOpenDirs((s) => { const n = new Set(s); n.has(p) ? n.delete(p) : n.add(p); return n })
}, [])
function reveal(path: string): void {
setOpenDirs((s) => { const n = new Set(s); ancestors(path).forEach((a) => n.add(a)); return n })
}
function commit(): void {
const msg = commitMsg.trim()
if (!msg) return
actions.commit(msg).then((n) => {
if (n > 0) toast(`Committed ${n} file${n > 1 ? 's' : ''}`, msg.length > 34 ? msg.slice(0, 34) + '…' : msg)
})
setCommitMsg('')
}
function openFile(path: string, opts: { diff?: boolean; line?: number } = {}): void {
const changed = !!proj.diffs[path]
actions.ensureFile(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 + '"]') as HTMLElement | null
if (row) { const ed = row.closest('.editor') as HTMLElement; const er = ed.getBoundingClientRect(), rr = row.getBoundingClientRect(); ed.scrollTop += (rr.top - er.top) - ed.clientHeight / 2 }
}, 70)
}
}
function closeTab(path: string): void {
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: React.MouseEvent, target: ContextTarget): void {
e.preventDefault(); e.stopPropagation()
const sparkSend = (ref: string): Menu['items'][number] => ({ 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() as string
const items: Menu['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 = proj.staged.has(target.path)
items.push(isStaged
? { icon: Icon.minus(), label: 'Unstage changes', onClick: () => actions.unstage(target.path) }
: { icon: Icon.plus(), label: 'Stage changes', onClick: () => actions.stage(target.path) })
items.push({ icon: Icon.diff(), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) })
items.push({ icon: Icon.discard(), label: 'Discard changes', onClick: () => doDiscard(target.path) })
}
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: KeyboardEvent): void {
const meta = e.metaKey || e.ctrlKey
if (meta && e.key.toLowerCase() === 'f') { e.preventDefault(); setOverlay('search') }
else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() }
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: Record<string, string> = { original: 'orig', updated: 'upd', diff: 'diff', code: '' }
const MODE_WORD: Record<string, string> = { original: 'Original', updated: 'Updated', diff: 'Diff' }
const resolvedTabs = tabs.map((t) => {
const changed = !!proj.diffs[t.path]
const m = tabMode[t.path] || (changed ? 'diff' : 'code')
return { ...t, changed, modeLabel: splitFor === t.path ? 'split' : MODE_LABEL[m], dirty: isDirty(t.path) }
})
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? 'diff' : 'code')
const totals = proj.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('/') : []
const curLine = cursor && active && cursor.path === active ? cursor.line : 1
const curCol = cursor && active && cursor.path === active ? cursor.col : 1
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)', cursor: 'pointer' }} title="Open folder…" onClick={() => actions.openFolder()}>{proj.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)' } : undefined}>{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 branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED}
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
onStage={actions.stage} onUnstage={actions.unstage} onStageAll={actions.stageAll} onUnstageAll={actions.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' }}>
{proj.tree ? (
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
onContext={openMenu} activePath={active} changeMap={changeMap} committed={NO_COMMITTED} />
) : (
<div className="phead"><span>Explorer</span></div>
)}
</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) => { if (active) { 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}
bufferText={bufferText(active)} onEdit={onEdit} />
</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' }}>{proj.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 ${curLine}, Col ${curCol}`}</div>}
{active && <div className="sb">Spaces: 4</div>}
{active && <div className="sb">UTF-8</div>}
{active && <div className="sb"><b>{activeLang}</b></div>}
{active && proj.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>
)
}

View File

@@ -0,0 +1,240 @@
/* Shared icons, FileIcon, GitPanel, FileTree */
import React, { Fragment } from 'react'
import type { Change, FileNode, GitStatus } from './types'
import { HL } from './highlight'
type SvgProps = React.SVGProps<SVGSVGElement>
/* ---- minimal geometric icons ---- */
export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
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>),
}
export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
<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>
)
export const FolderIcon = ({ open }: { open: boolean }): React.ReactElement => (
<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>
)
export function FileIcon({ path }: { path: string }): React.ReactElement {
const ic = HL.iconFor(path)
return <span className="ficon" style={{ background: ic.c }}><span>{ic.t}</span></span>
}
/* Shared callback signatures used across panels. */
export type OpenFile = (path: string, opts?: { diff?: boolean; line?: number }) => void
export interface ContextTarget {
path: string
kind: 'editor' | 'dir' | 'file' | 'git'
staged?: boolean
sel?: { start: number; end: number }
line?: number
}
export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
/* ============ Git / Source Control panel ============ */
function GitRow({ c, staged, activePath, onOpen, onContext, onToggleStage }: {
c: Change
staged: boolean
activePath: string | null
onOpen: OpenFile
onContext: OnContext
onToggleStage: (path: string) => void
}): React.ReactElement {
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>
)
}
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath }: {
branch: string
changes: Change[]
staged: Set<string>
committed: Set<string>
commitMsg: string
setCommitMsg: (v: string) => void
onStage: (path: string) => void
onUnstage: (path: string) => void
onStageAll: () => void
onUnstageAll: () => void
onCommit: () => void
onOpen: OpenFile
onContext: OnContext
activePath: string | null
}): React.ReactElement {
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 (
<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>
) : (
<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>
)}
</Fragment>
)}
</div>
<div className="git-foot">
<span className="branch-chip">{Icon.branch()}<b>{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>
</Fragment>
)
}
/* ============ File Tree ============ */
function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed }: {
node: FileNode
depth: number
openDirs: Set<string>
toggleDir: (path: string) => void
onOpen: OpenFile
onContext: OnContext
activePath: string | null
changeMap: Record<string, GitStatus>
committed: Set<string>
}): React.ReactElement {
const pad = 10 + depth * 13
if (node.type === 'dir') {
const isOpen = openDirs.has(node.path) || node.path === ''
return (
<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} />
))}
</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)' } : undefined}>{node.name}</span>
{status && <span className={'tree-badge ' + status}>{status}</span>}
</div>
)
}
export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed }: {
tree: FileNode
openDirs: Set<string>
toggleDir: (path: string) => void
onOpen: OpenFile
onContext: OnContext
activePath: string | null
changeMap: Record<string, GitStatus>
committed: Set<string>
}): React.ReactElement {
return (
<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>
</Fragment>
)
}

View File

@@ -1,9 +1,17 @@
/* Mock project: filesystem tree, file contents, before/after pairs, runtime diff. */ /* Mock project: filesystem tree, file contents, before/after pairs, runtime diff.
(function () { *
// ---- working-tree (current / updated) file contents ---------------- * PHASE 2: replace this whole module with real data streamed from the main
const F = {}; * process file tree (chokidar), file contents on demand, and `git diff`
* derived original/updated pairs. The four view modes still derive from the
* same original/updated text pair per changed file, so keep buildDiff()'s
* output shape. */
import type { Change, Diff, FileNode, Project } from './types'
import { buildDiff } from './diff'
F["src/Http/Controller/UserController.php"] = `<?php // ---- working-tree (current / updated) file contents ----------------
const F: Record<string, string> = {}
F['src/Http/Controller/UserController.php'] = `<?php
namespace App\\Http\\Controller; namespace App\\Http\\Controller;
@@ -60,9 +68,9 @@ final class UserController
return array_intersect_key($data, array_flip($allowed)); return array_intersect_key($data, array_flip($allowed));
} }
} }
`; `
F["src/Service/PaymentService.php"] = `<?php F['src/Service/PaymentService.php'] = `<?php
namespace App\\Service; namespace App\\Service;
@@ -101,9 +109,9 @@ final class PaymentService
return $result->success; return $result->success;
} }
} }
`; `
F["public/assets/app.js"] = `import { createStore } from './store.js'; F['public/assets/app.js'] = `import { createStore } from './store.js';
import { mountRouter } from './router.js'; import { mountRouter } from './router.js';
const store = createStore({ const store = createStore({
@@ -135,9 +143,9 @@ function renderToasts(list) {
} }
document.addEventListener('DOMContentLoaded', bootstrap); document.addEventListener('DOMContentLoaded', bootstrap);
`; `
F["public/assets/store.js"] = `export function createStore(initial = {}) { F['public/assets/store.js'] = `export function createStore(initial = {}) {
let state = { ...initial }; let state = { ...initial };
const subs = new Map(); const subs = new Map();
@@ -155,9 +163,9 @@ document.addEventListener('DOMContentLoaded', bootstrap);
}, },
}; };
} }
`; `
F["public/assets/styles.css"] = `:root { F['public/assets/styles.css'] = `:root {
--brand: #4d8dff; --brand: #4d8dff;
--ink: #15171a; --ink: #15171a;
--paper: #ffffff; --paper: #ffffff;
@@ -179,9 +187,9 @@ body {
.toast--error { border-left-color: #e0696a; } .toast--error { border-left-color: #e0696a; }
.toast--success { border-left-color: #5cbd6b; } .toast--success { border-left-color: #5cbd6b; }
`; `
F["src/types/api.ts"] = `export type Plan = 'free' | 'pro' | 'enterprise'; F['src/types/api.ts'] = `export type Plan = 'free' | 'pro' | 'enterprise';
export interface User { export interface User {
id: string; id: string;
@@ -209,9 +217,9 @@ export async function getUser(id: string): Promise<ApiResult<User>> {
} }
return { ok: true, data: (await res.json()) as User }; return { ok: true, data: (await res.json()) as User };
} }
`; `
F["scripts/migrate.py"] = `#!/usr/bin/env python3 F['scripts/migrate.py'] = `#!/usr/bin/env python3
"""Run pending database migrations in order.""" """Run pending database migrations in order."""
import sys import sys
from pathlib import Path from pathlib import Path
@@ -254,9 +262,9 @@ def main() -> int:
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
`; `
F["scripts/seed.py"] = `#!/usr/bin/env python3 F['scripts/seed.py'] = `#!/usr/bin/env python3
"""Seed the database with demo data for local development.""" """Seed the database with demo data for local development."""
import random import random
from db import connect from db import connect
@@ -277,9 +285,9 @@ def seed_users(conn, count: int = 25) -> None:
if __name__ == "__main__": if __name__ == "__main__":
seed_users(connect()) seed_users(connect())
`; `
F["templates/dashboard.html"] = `<!doctype html> F['templates/dashboard.html'] = `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@@ -302,9 +310,9 @@ if __name__ == "__main__":
<script type="module" src="/assets/app.js"></script> <script type="module" src="/assets/app.js"></script>
</body> </body>
</html> </html>
`; `
F["config/app.json"] = `{ F['config/app.json'] = `{
"name": "console", "name": "console",
"env": "production", "env": "production",
"features": { "features": {
@@ -322,9 +330,9 @@ if __name__ == "__main__":
"channel": "stdout" "channel": "stdout"
} }
} }
`; `
F["composer.json"] = `{ F['composer.json'] = `{
"name": "blijnder/console", "name": "blijnder/console",
"type": "project", "type": "project",
"require": { "require": {
@@ -341,9 +349,9 @@ if __name__ == "__main__":
"psr-4": { "App\\\\": "src/" } "psr-4": { "App\\\\": "src/" }
} }
} }
`; `
F["package.json"] = `{ F['package.json'] = `{
"name": "console-frontend", "name": "console-frontend",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -359,9 +367,9 @@ if __name__ == "__main__":
"typescript": "^5.5.0" "typescript": "^5.5.0"
} }
} }
`; `
F["README.md"] = `# Console F['README.md'] = `# Console
Internal admin console. PHP API + small vanilla JS frontend. Internal admin console. PHP API + small vanilla JS frontend.
@@ -374,24 +382,24 @@ Internal admin console. PHP API + small vanilla JS frontend.
## Layout ## Layout
- \`src/\` PHP application code (PSR-4, \`App\\\` namespace) - \\\`src/\\\` PHP application code (PSR-4, \\\`App\\\\\\\` namespace)
- \`public/\` Document root and frontend assets - \\\`public/\\\` Document root and frontend assets
- \`scripts/\` Python maintenance + migration scripts - \\\`scripts/\\\` Python maintenance + migration scripts
- \`templates/\` Server-rendered HTML - \\\`templates/\\\` Server-rendered HTML
`; `
F[".env"] = `APP_ENV=production F['.env'] = `APP_ENV=production
APP_DEBUG=false APP_DEBUG=false
DATABASE_URL=postgres://localhost:5432/console DATABASE_URL=postgres://localhost:5432/console
PAYMENT_GATEWAY=stripe PAYMENT_GATEWAY=stripe
PAYMENT_CURRENCY=EUR PAYMENT_CURRENCY=EUR
LOG_LEVEL=info LOG_LEVEL=info
`; `
// ---- ORIGINAL (pre-edit) versions of changed files ---------------- // ---- ORIGINAL (pre-edit) versions of changed files ----------------
const O = {}; const O: Record<string, string> = {}
O["src/Http/Controller/UserController.php"] = `<?php O['src/Http/Controller/UserController.php'] = `<?php
namespace App\\Http\\Controller; namespace App\\Http\\Controller;
@@ -444,9 +452,9 @@ final class UserController
return array_intersect_key($data, array_flip(['email', 'plan'])); return array_intersect_key($data, array_flip(['email', 'plan']));
} }
} }
`; `
O["public/assets/app.js"] = `import { createStore } from './store.js'; O['public/assets/app.js'] = `import { createStore } from './store.js';
import { mountRouter } from './router.js'; import { mountRouter } from './router.js';
const store = createStore({ const store = createStore({
@@ -477,9 +485,9 @@ function renderToasts(list) {
} }
document.addEventListener('DOMContentLoaded', bootstrap); document.addEventListener('DOMContentLoaded', bootstrap);
`; `
O["config/app.json"] = `{ O['config/app.json'] = `{
"name": "console", "name": "console",
"env": "production", "env": "production",
"features": { "features": {
@@ -496,9 +504,9 @@ document.addEventListener('DOMContentLoaded', bootstrap);
"channel": "stdout" "channel": "stdout"
} }
} }
`; `
O["scripts/migrate.py"] = `#!/usr/bin/env python3 O['scripts/migrate.py'] = `#!/usr/bin/env python3
"""Run pending database migrations in order.""" """Run pending database migrations in order."""
import sys import sys
from pathlib import Path from pathlib import Path
@@ -540,13 +548,13 @@ def main() -> int:
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
`; `
// PaymentService is a brand-new file (added) -> original is empty // PaymentService is a brand-new file (added) -> original is empty
O["src/Service/PaymentService.php"] = ""; O['src/Service/PaymentService.php'] = ''
// LegacyUser was deleted -> original content, no working-tree version // LegacyUser was deleted -> original content, no working-tree version
O["src/Model/LegacyUser.php"] = `<?php O['src/Model/LegacyUser.php'] = `<?php
namespace App\\Model; namespace App\\Model;
@@ -580,133 +588,78 @@ final class LegacyUser
]; ];
} }
} }
`; `
// ---- file tree (nested) ------------------------------------------- // ---- file tree (nested) -------------------------------------------
const tree = { const tree: FileNode = {
name: "console", type: "dir", path: "", open: true, children: [ name: 'console', type: 'dir', path: '', open: true, children: [
{ name: "config", type: "dir", path: "config", open: false, children: [ { name: 'config', type: 'dir', path: 'config', open: false, children: [
{ name: "app.json", type: "file", path: "config/app.json" }, { name: 'app.json', type: 'file', path: 'config/app.json' },
]}, ] },
{ name: "public", type: "dir", path: "public", open: true, children: [ { name: 'public', type: 'dir', path: 'public', open: true, children: [
{ name: "assets", type: "dir", path: "public/assets", open: true, children: [ { name: 'assets', type: 'dir', path: 'public/assets', open: true, children: [
{ name: "app.js", type: "file", path: "public/assets/app.js" }, { name: 'app.js', type: 'file', path: 'public/assets/app.js' },
{ name: "store.js", type: "file", path: "public/assets/store.js" }, { name: 'store.js', type: 'file', path: 'public/assets/store.js' },
{ name: "styles.css", type: "file", path: "public/assets/styles.css" }, { name: 'styles.css', type: 'file', path: 'public/assets/styles.css' },
]}, ] },
]}, ] },
{ name: "scripts", type: "dir", path: "scripts", open: false, children: [ { name: 'scripts', type: 'dir', path: 'scripts', open: false, children: [
{ name: "migrate.py", type: "file", path: "scripts/migrate.py" }, { name: 'migrate.py', type: 'file', path: 'scripts/migrate.py' },
{ name: "seed.py", type: "file", path: "scripts/seed.py" }, { name: 'seed.py', type: 'file', path: 'scripts/seed.py' },
]}, ] },
{ name: "src", type: "dir", path: "src", open: true, children: [ { name: 'src', type: 'dir', path: 'src', open: true, children: [
{ name: "Http", type: "dir", path: "src/Http", 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: 'Controller', type: 'dir', path: 'src/Http/Controller', open: true, children: [
{ name: "UserController.php", type: "file", path: "src/Http/Controller/UserController.php" }, { name: 'UserController.php', type: 'file', path: 'src/Http/Controller/UserController.php' },
]}, ] },
]}, ] },
{ name: "Service", type: "dir", path: "src/Service", open: true, children: [ { name: 'Service', type: 'dir', path: 'src/Service', open: true, children: [
{ name: "PaymentService.php", type: "file", path: "src/Service/PaymentService.php" }, { name: 'PaymentService.php', type: 'file', path: 'src/Service/PaymentService.php' },
]}, ] },
{ name: "types", type: "dir", path: "src/types", open: false, children: [ { name: 'types', type: 'dir', path: 'src/types', open: false, children: [
{ name: "api.ts", type: "file", path: "src/types/api.ts" }, { name: 'api.ts', type: 'file', path: 'src/types/api.ts' },
]}, ] },
]}, ] },
{ name: "templates", type: "dir", path: "templates", open: false, children: [ { name: 'templates', type: 'dir', path: 'templates', open: false, children: [
{ name: "dashboard.html", type: "file", path: "templates/dashboard.html" }, { name: 'dashboard.html', type: 'file', path: 'templates/dashboard.html' },
]}, ] },
{ name: ".env", type: "file", path: ".env" }, { name: '.env', type: 'file', path: '.env' },
{ name: "composer.json", type: "file", path: "composer.json" }, { name: 'composer.json', type: 'file', path: 'composer.json' },
{ name: "package.json", type: "file", path: "package.json" }, { name: 'package.json', type: 'file', path: 'package.json' },
{ name: "README.md", type: "file", path: "README.md" }, { name: 'README.md', type: 'file', path: 'README.md' },
], ],
}; }
// ---- line-based LCS diff ------------------------------------------ // ---- changed files -------------------------------------------------
function buildDiff(origText, updText) { const changeDefs: { path: string; status: Change['status'] }[] = [
const a = origText === "" ? [] : origText.replace(/\n$/, "").split("\n"); { path: 'src/Service/PaymentService.php', status: 'A' },
const b = updText === "" ? [] : updText.replace(/\n$/, "").split("\n"); { path: 'src/Http/Controller/UserController.php', status: 'M' },
const n = a.length, m = b.length; { path: 'public/assets/app.js', status: 'M' },
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1)); { path: 'config/app.json', status: 'M' },
for (let i = n - 1; i >= 0; i--) { path: 'scripts/migrate.py', status: 'M' },
for (let j = m - 1; j >= 0; j--) { path: 'src/Model/LegacyUser.php', status: 'D' },
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 = []; const diffs: Record<string, Diff> = {}
let i = 0, j = 0; const changes: Change[] = changeDefs.map((c) => {
while (i < n && j < m) { const orig = O[c.path] != null ? O[c.path] : ''
if (a[i] === b[j]) { ops.push({ t: "same", a: i, b: j }); i++; j++; } const upd = F[c.path] != null ? F[c.path] : ''
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: "del", a: i }); i++; } const d = buildDiff(orig, upd)
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, { diffs[c.path] = Object.assign(d, {
deleted: c.status === "D", deleted: c.status === 'D',
added: c.status === "A", added: c.status === 'A',
original: orig, original: orig,
updated: upd, updated: upd,
}); })
return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === "D" }; return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D' }
}); })
window.PROJECT = { export const PROJECT: Project = {
name: "console", name: 'console',
branch: "feat/payments-balance", branch: 'feat/payments-balance',
files: F, files: F,
originals: O, originals: O,
tree, tree,
diffs, diffs,
changes, changes,
}; }
})();

66
src/renderer/src/diff.ts Normal file
View File

@@ -0,0 +1,66 @@
/* Line-based LCS diff — the single source for the four view modes.
* Original / Updated / Diff / Split all derive from one (original, updated)
* text pair per changed file, whether that pair comes from the mock or from
* real `git diff` (original = HEAD:path, updated = working tree). */
import type { Diff, DiffRow, GitStatus, SideLine, SplitRow } from './types'
interface Op { t: 'same' | 'del' | 'add'; a?: number; b?: number }
export function buildDiff(origText: string, updText: string): Omit<Diff, 'deleted' | 'added' | 'original' | 'updated'> {
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: Op[] = []
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: DiffRow[] = [], left: SideLine[] = [], right: SideLine[] = [], split: SplitRow[] = []
const delSet = new Set<number>(), addSet = new Set<number>()
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: SideLine[] = [], abuf: SideLine[] = []
const flush = (): void => {
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 }
}
/** Assemble a full Diff from a status + original/updated pair. */
export function makeDiff(status: GitStatus, original: string, updated: string): Diff {
const d = buildDiff(original, updated)
return { ...d, deleted: status === 'D', added: status === 'A', original, updated }
}

389
src/renderer/src/editor.tsx Normal file
View File

@@ -0,0 +1,389 @@
/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */
import React, { Fragment, useEffect, useMemo, useRef } from 'react'
import type { Diff, ViewLine } from './types'
import { useProject } from './project'
import { HL } from './highlight'
import { FileIcon, Icon } from './components'
import type { OnContext } from './components'
export interface Cursor { path: string; line: number; col: number }
export interface Selection { path: string; start: number; end: number; anchor: number }
export type Mode = 'original' | 'updated' | 'diff' | 'code'
function climbToLine(node: Node | null): HTMLElement | null {
let el: HTMLElement | null = node && node.nodeType === 3 ? (node.parentElement as HTMLElement) : (node as HTMLElement | null)
while (el && !(el.dataset && el.dataset.line)) el = el.parentElement
return el || null
}
interface ResolvedTab { path: string; changed: boolean; modeLabel: string; dirty?: boolean }
/* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a
* scroll-synced line-number gutter. Live highlighting while typing. */
function CodeEditor({ path, text, lang, onChange, onContext }: {
path: string
text: string
lang: string | null
onChange: (text: string) => void
onContext: OnContext
}): React.ReactElement {
const scrollRef = useRef<HTMLDivElement>(null)
const gutterRef = useRef<HTMLDivElement>(null)
const html = useMemo(() => HL.hlText(text, lang), [text, lang])
const count = useMemo(() => text.split('\n').length, [text])
function onScroll(): void {
const s = scrollRef.current
if (s && gutterRef.current) gutterRef.current.style.transform = `translateY(${-s.scrollTop}px)`
}
// The textarea is overflow-hidden under the scroller, so keep the caret line
// in view by scrolling the container ourselves (6px top pad, 20px line-height).
function ensureCaretVisible(ta: HTMLTextAreaElement): void {
const s = scrollRef.current
if (!s) return
const line = ta.value.slice(0, ta.selectionStart).split('\n').length - 1
const top = 6 + line * 20
const bottom = top + 20
if (top < s.scrollTop) s.scrollTop = top - 20
else if (bottom > s.scrollTop + s.clientHeight) s.scrollTop = bottom - s.clientHeight + 20
}
function handleContext(e: React.MouseEvent<HTMLTextAreaElement>): void {
e.preventDefault()
const ta = e.currentTarget
const startLine = text.slice(0, ta.selectionStart).split('\n').length
const info: Parameters<OnContext>[1] = { path, kind: 'editor', line: startLine }
if (ta.selectionEnd > ta.selectionStart) {
const endLine = text.slice(0, ta.selectionEnd).split('\n').length
if (endLine !== startLine) info.sel = { start: startLine, end: endLine }
}
onContext(e, info)
}
return (
<div className="code-edit">
<div className="ce-gutterwrap">
<div className="ce-gutter" ref={gutterRef}>
{Array.from({ length: count }, (_, i) => <div key={i}>{i + 1}</div>)}
</div>
</div>
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="ce-inner">
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
wrap="off"
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
onClick={(e) => ensureCaretVisible(e.currentTarget)}
onContextMenu={handleContext} />
<pre className="ce-pre" aria-hidden dangerouslySetInnerHTML={{ __html: html + '\n' }} />
</div>
</div>
</div>
)
}
function EditorTabs({ tabs, active, onActivate, onClose }: {
tabs: ResolvedTab[]
active: string | null
onActivate: (path: string) => void
onClose: (path: string) => void
}): React.ReactElement {
const ref = useRef<HTMLDivElement>(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' : '') + (t.dirty ? ' dirty' : '')}
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, cursor, selection, setCursor, setSelection, onContext }: {
cacheKey: string
path: string
lines: ViewLine[]
lang: string | null
showSign: boolean
cursor: Cursor | null
selection: Selection | null
setCursor: (c: Cursor) => void
setSelection: (s: Selection | null) => void
onContext: OnContext
}): React.ReactElement {
const anchorRef = useRef<number | null>(null)
const html = useMemo(() => lines.map((l) => HL.hlLine(l.text, lang)), [cacheKey])
function gutterClick(e: React.MouseEvent, no: number | null): void {
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: globalThis.Selection): number {
try {
const el = climbToLine(sel.focusNode)
const code = el!.querySelector('.ln-code') as Element
const r = document.createRange()
r.setStart(code, 0); r.setEnd(sel.focusNode!, sel.focusOffset)
return r.toString().length + 1
} catch {
return 1
}
}
function onMouseUp(): void {
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: React.MouseEvent): void {
e.preventDefault()
const sel = window.getSelection()
const info: Parameters<OnContext>[1] = { 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: number | null = 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: Mode, diff: Diff | null, fileText: string | undefined): { lines: ViewLine[]; showSign: boolean } {
if (mode === 'original' && diff) return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'del' ? 'bar-del' : null })), showSign: false }
if (mode === 'updated' && diff) return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'add' ? 'bar-add' : null })), showSign: false }
if (mode === 'diff' && 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: Mode; label: string }[] = [
{ id: 'original', label: 'Original' },
{ id: 'updated', label: 'Updated' },
{ id: 'diff', label: 'Diff' },
]
export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
tabs: ResolvedTab[]
active: string | null
mode: Mode
setMode: (m: Mode) => void
onActivate: (path: string) => void
onClose: (path: string) => void
onContext: OnContext
onSplit: (path: string) => void
splitOpen: boolean
cursor: Cursor | null
selection: Selection | null
setCursor: (c: Cursor) => void
setSelection: (s: Selection | null) => void
bufferText: string
onEdit: (text: string) => void
}): React.ReactElement {
const PROJECT = useProject()
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: Mode = change ? mode : 'code'
let built: { lines: ViewLine[]; showSign: boolean } | null = 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
// Editable in the live-buffer modes; Original/Diff stay read-only review views.
const editable = effMode === 'code' || effMode === 'updated'
return (
<Fragment>
<EditorTabs tabs={tabs} active={active} onActivate={onActivate} onClose={onClose} />
{!tab ? (
<div className="empty-ed">
<div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</div>
<div className="big">No file open</div>
<div className="klist">
<div><span>Search files &amp; 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>
) : editable ? (
<CodeEditor path={tab.path} text={bufferText} lang={lang} onChange={onEdit} onContext={onContext} />
) : (
built && <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>
)}
</Fragment>
)
}
/* Full-screen side-by-side split view */
export function SplitView({ path, onClose, onContext }: {
path: string
onClose: () => void
onContext: OnContext
}): React.ReactElement {
const PROJECT = useProject()
const diff = PROJECT.diffs[path]
const lang = HL.langFor(path)
const leftRef = useRef<HTMLDivElement>(null), rightRef = useRef<HTMLDivElement>(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: HTMLDivElement | null, to: HTMLDivElement | null): void {
if (lock.current || !from || !to) return
lock.current = true
to.scrollTop = from.scrollTop; to.scrollLeft = from.scrollLeft
requestAnimationFrame(() => { lock.current = false })
}
function ctx(e: React.MouseEvent): void {
e.preventDefault()
let no: number | null = 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={ctx}>
{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={ctx}>
{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>
)
}

59
src/renderer/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,59 @@
/// <reference types="vite/client" />
import type { FileNode, GitStatus, HelderConfig } from './types'
interface GitChangeRaw {
path: string
status: GitStatus
staged: boolean
original: string
updated: string
}
interface HelderBridge {
platform: string
clipboard: { writeText: (text: string) => void }
project: {
current: () => Promise<{ root: string | null; name: string }>
open: () => Promise<{ root: string | null; name: string }>
}
fs: {
tree: () => Promise<FileNode | null>
files: () => Promise<Record<string, string>>
read: (path: string) => Promise<string>
write: (path: string, content: string) => Promise<void>
}
git: {
load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null>
stage: (paths: string[]) => Promise<void>
unstage: (paths: string[]) => Promise<void>
commit: (message: string) => Promise<void>
discard: (paths: string[]) => Promise<void>
}
pty: {
available: () => Promise<boolean>
create: (kind: 'agent' | 'shell', cols: number, rows: number) => Promise<number>
write: (id: number, data: string) => void
resize: (id: number, cols: number, rows: number) => void
kill: (id: number) => void
onData: (cb: (id: number, data: string) => void) => () => void
onExit: (cb: (id: number) => void) => () => void
}
config: {
get: () => Promise<HelderConfig>
theme: () => Promise<string>
}
search: {
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
files: () => Promise<string[]>
}
onProjectChanged: (cb: () => void) => () => void
onConfigChanged: (cb: () => void) => () => void
}
declare global {
interface Window {
helder?: HelderBridge
}
}
export {}

View File

@@ -0,0 +1,115 @@
/* Syntax highlighting (Prism) + file-type icon metadata.
*
* The default `prismjs` bundle already registers markup, css, clike and
* javascript. We add the rest in dependency order. CRITICAL: prism-php requires
* prism-markup-templating to be loaded FIRST, or every Prism.highlight() call
* throws and silently falls back to plain text. */
import Prism from 'prismjs'
import 'prismjs/components/prism-markup-templating'
import 'prismjs/components/prism-php'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-typescript'
import 'prismjs/components/prism-jsx'
import 'prismjs/components/prism-tsx'
import 'prismjs/components/prism-json'
import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-yaml'
import 'prismjs/components/prism-markdown'
// We drive highlighting manually; no DOM auto-scan.
Prism.manual = true
const EXT_LANG: Record<string, string> = {
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: string): string {
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: string): string | null {
return EXT_LANG[ext(path)] || null
}
function langLabel(path: string): string {
const e = ext(path)
const map: Record<string, string> = {
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: string): string {
return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c] as string))
}
// highlight a single line independently (keeps line numbering robust)
function hlLine(line: string, lang: string | null): string {
if (line === '') return '&nbsp;'
try {
const grammar = lang ? Prism.languages[lang] : null
if (grammar) return Prism.highlight(line, grammar, lang as string)
} catch {
/* fall through */
}
return escapeHtml(line)
}
// highlight a whole multi-line block (for the editable buffer's display layer)
function hlText(text: string, lang: string | null): string {
try {
const grammar = lang ? Prism.languages[lang] : null
if (grammar) return Prism.highlight(text, grammar, lang as string)
} catch {
/* fall through */
}
return escapeHtml(text)
}
// ---- file-type icon: colored monogram chip --------------------------
interface IconMeta { c: string; t: string }
const ICONS: Record<string, IconMeta> = {
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: Record<string, IconMeta> = {
'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: string): IconMeta {
const base = path.split('/').pop() || ''
if (NAME_ICONS[base]) return NAME_ICONS[base]
return ICONS[ext(path)] || { c: '#7d838c', t: base.slice(0, 2) || '·' }
}
export const HL = { ext, langFor, langLabel, hlLine, hlText, iconFor, escapeHtml }

23
src/renderer/src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
// Bundled locally (no Google Fonts CDN in Electron). Weights used by the UI.
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/500.css'
import '@fontsource/jetbrains-mono/600.css'
import '@fontsource/jetbrains-mono/700.css'
// Initialises Prism + all grammars (correct php load order) as a side effect.
import './highlight'
import './styles.css'
import { App } from './App'
import { ProjectProvider } from './project'
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ProjectProvider>
<App />
</ProjectProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,267 @@
/* Overlays: combined search (content + file names), context menu, toast, pass-popup */
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { useProject } from './project'
import { FileIcon, Icon } from './components'
import type { OpenFile } from './components'
export interface MenuItem {
sep?: boolean
primary?: boolean
icon?: React.ReactElement
label?: string
kbd?: string
onClick?: () => void
}
export interface Menu { x: number; y: number; note?: string; items: MenuItem[] }
export interface Toast { id: number; title: string; ref?: string }
interface ContentHit { no: number; ln: string; ix: number }
interface ContentGroup { path: string; hits: ContentHit[] }
export function fuzzy(q: string, str: string): number[] | null {
q = q.toLowerCase(); const s = str.toLowerCase()
let i = 0; const idx: number[] = []
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 }: { text: string; idx: number[] | null }): React.ReactElement {
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> : <Fragment key={i}>{ch}</Fragment>)}</span>
}
export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
onOpen: OpenFile
onOpenAt: (path: string, line: number) => void
onClose: () => void
changeSet: Set<string>
}): React.ReactElement {
const PROJECT = useProject()
const bridge = window.helder
const [q, setQ] = useState('')
const [sel, setSel] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const leftRef = useRef<HTMLDivElement>(null)
// file-name list: ripgrep `--files` when available, else the in-memory index keys
const [allPaths, setAllPaths] = useState<string[]>(() => (bridge ? [] : Object.keys(PROJECT.files)))
useEffect(() => {
inputRef.current && inputRef.current.focus()
if (bridge) bridge.search.files().then((f) => setAllPaths(f.length ? f : Object.keys(PROJECT.files))).catch(() => setAllPaths(Object.keys(PROJECT.files)))
}, [])
// content hits (left): ripgrep (debounced) when available, else in-memory substring grep
const [content, setContent] = useState<ContentGroup[]>([])
useEffect(() => {
const term = q.trim()
if (term.length < 2) { setContent([]); return }
if (bridge) {
let alive = true
const t = setTimeout(() => {
bridge.search.content(term).then((g) => { if (alive) setContent(g) }).catch(() => { if (alive) setContent([]) })
}, 120)
return () => { alive = false; clearTimeout(t) }
}
const low = term.toLowerCase()
const groups: ContentGroup[] = []
for (const [path, src] of Object.entries(PROJECT.files)) {
const lines = src.split('\n')
const hits: ContentHit[] = []
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 })
}
setContent(groups)
return
}, [q])
// file-name matches (right)
const files = useMemo(() => {
const term = q.trim()
if (!term) return []
const out: { path: string; idx: number[] | null; rank: number; pos: number }[] = []
for (const p of allPaths) {
const name = p.split('/').pop() as string
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, allPaths])
// flat list of content hits for keyboard nav
const flat = useMemo(() => {
const arr: { path: string; no: number }[] = []
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: React.KeyboardEvent): void {
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: string, ix: number, len: number): React.ReactElement {
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) => (
<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>
)
})}
</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() as string
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>
)
}
export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const h = (e: MouseEvent): void => { if (ref.current && !ref.current.contains(e.target as Node)) onClose() }
const k = (e: KeyboardEvent): void => { 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 && it.onClick(); onClose() }}>
<span className="ic">{it.icon}</span>
<span>{it.label}</span>
{it.kbd && <span className="kc">{it.kbd}</span>}
</div>
))}
</div>
)
}
export function Toasts({ toasts }: { toasts: Toast[] }): React.ReactElement {
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>
)
}
export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
x: number
y: number
refStr: string
onConfirm: (text: string) => void
onCancel: () => void
}): React.ReactElement {
const [text, setText] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const boxRef = useRef<HTMLDivElement>(null)
useEffect(() => { inputRef.current && inputRef.current.focus() }, [])
useEffect(() => {
const h = (e: MouseEvent): void => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) onCancel() }
const k = (e: KeyboardEvent): void => { 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>
)
}

View File

@@ -0,0 +1,186 @@
/* Renderer-side project store. Loads tree / file index / git state from the
* main process over the preload bridge and exposes it in the same shape the UI
* already consumed from the mock. When window.helder is absent (e.g. a plain
* browser preview) it falls back to the mock so the UI still renders. */
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import type { Change, Diff, FileNode, HelderConfig } from './types'
import { DEFAULT_CONFIG } from './types'
import { makeDiff } from './diff'
import { PROJECT as MOCK } from './data'
export interface ProjectData {
name: string
root: string | null
branch: string
tree: FileNode | null
files: Record<string, string>
changes: Change[]
diffs: Record<string, Diff>
staged: Set<string>
config: HelderConfig
isRepo: boolean
ready: boolean
}
/** Inject the project's theme.css over the built-in dark theme. */
function applyTheme(css: string): void {
let el = document.getElementById('helder-theme') as HTMLStyleElement | null
if (!el) {
el = document.createElement('style')
el.id = 'helder-theme'
document.head.appendChild(el)
}
el.textContent = css || ''
}
export interface ProjectActions {
openFolder: () => void
refresh: () => void
stage: (path: string) => void
unstage: (path: string) => void
stageAll: () => void
unstageAll: () => void
commit: (message: string) => Promise<number>
discard: (path: string) => void
ensureFile: (path: string) => void
}
const MOCK_STAGED = ['src/Service/PaymentService.php', 'config/app.json']
function mockData(): ProjectData {
return {
name: MOCK.name, root: null, branch: MOCK.branch,
tree: MOCK.tree, files: MOCK.files, changes: MOCK.changes, diffs: MOCK.diffs,
staged: new Set(MOCK_STAGED), config: DEFAULT_CONFIG, isRepo: true, ready: true,
}
}
const emptyData: ProjectData = {
name: 'Loading…', root: null, branch: '—', tree: null, files: {},
changes: [], diffs: {}, staged: new Set(), config: DEFAULT_CONFIG, isRepo: false, ready: false,
}
const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({
data: emptyData,
actions: {} as ProjectActions,
})
export function useProject(): ProjectData {
return useContext(Ctx).data
}
export function useProjectActions(): ProjectActions {
return useContext(Ctx).actions
}
export function ProjectProvider({ children }: { children: React.ReactNode }): React.ReactElement {
const bridge = window.helder
const [data, setData] = useState<ProjectData>(emptyData)
const dataRef = useRef(data)
dataRef.current = data
async function loadReal(): Promise<void> {
if (!bridge) return
const [cur, tree, files, git, config, theme] = await Promise.all([
bridge.project.current(),
bridge.fs.tree(),
bridge.fs.files(),
bridge.git.load(),
bridge.config.get(),
bridge.config.theme(),
])
applyTheme(theme)
const changes: Change[] = []
const diffs: Record<string, Diff> = {}
const staged = new Set<string>()
if (git) {
for (const c of git.changes) {
const d = makeDiff(c.status, c.original, c.updated)
diffs[c.path] = d
changes.push({ path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D' })
if (c.staged) staged.add(c.path)
}
}
setData({
name: cur.name, root: cur.root, branch: git ? git.branch : '—',
tree, files: files || {}, changes, diffs, staged, config, isRepo: !!git, ready: true,
})
}
async function loadConfigTheme(): Promise<void> {
if (!bridge) return
const [config, theme] = await Promise.all([bridge.config.get(), bridge.config.theme()])
applyTheme(theme)
setData((d) => ({ ...d, config }))
}
useEffect(() => {
if (!bridge) { setData(mockData()); return }
loadReal().catch(() => setData((d) => ({ ...d, ready: true })))
const offProject = bridge.onProjectChanged(() => { loadReal().catch(() => {}) })
const offConfig = bridge.onConfigChanged(() => { loadConfigTheme().catch(() => {}) })
return () => { offProject(); offConfig() }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const actions = useMemo<ProjectActions>(() => {
if (!bridge) {
// ---- mock-mode actions (preview only) ----
const setStaged = (fn: (s: Set<string>) => Set<string>): void =>
setData((d) => ({ ...d, staged: fn(new Set(d.staged)) }))
return {
openFolder: () => {},
refresh: () => setData(mockData()),
stage: (p) => setStaged((s) => (s.add(p), s)),
unstage: (p) => setStaged((s) => (s.delete(p), s)),
stageAll: () => setData((d) => ({ ...d, staged: new Set(d.changes.map((c) => c.path)) })),
unstageAll: () => setData((d) => ({ ...d, staged: new Set() })),
commit: async (_msg) => {
const cur = dataRef.current
const n = cur.changes.filter((c) => cur.staged.has(c.path)).length
setData((d) => ({ ...d, changes: d.changes.filter((c) => !d.staged.has(c.path)), staged: new Set() }))
return n
},
discard: (p) => setData((d) => ({
...d,
changes: d.changes.filter((c) => c.path !== p),
staged: (() => { const s = new Set(d.staged); s.delete(p); return s })(),
})),
ensureFile: () => {},
}
}
// ---- real git-backed actions ----
const after = (op: Promise<unknown>): void => { op.then(() => loadReal()).catch(() => {}) }
return {
openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) },
refresh: () => { loadReal().catch(() => {}) },
stage: (p) => after(bridge.git.stage([p])),
unstage: (p) => after(bridge.git.unstage([p])),
stageAll: () => {
const cur = dataRef.current
const unstaged = cur.changes.filter((c) => !cur.staged.has(c.path)).map((c) => c.path)
if (unstaged.length) after(bridge.git.stage(unstaged))
},
unstageAll: () => {
const staged = [...dataRef.current.staged]
if (staged.length) after(bridge.git.unstage(staged))
},
commit: async (msg) => {
const cur = dataRef.current
const n = cur.changes.filter((c) => cur.staged.has(c.path)).length
await bridge.git.commit(msg)
await loadReal()
return n
},
discard: (p) => after(bridge.git.discard([p])),
ensureFile: (path) => {
if (dataRef.current.files[path] != null) return
bridge.fs.read(path).then((txt) => {
setData((d) => (d.files[path] != null ? d : { ...d, files: { ...d.files, [path]: txt } }))
}).catch(() => {})
},
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return <Ctx.Provider value={{ data, actions }}>{children}</Ctx.Provider>
}

View File

@@ -1,4 +1,4 @@
/* ============ Agentic Coding Panel — dark, charcoal-neutral ============ */ /* ============ Helder — dark, charcoal-neutral (ported from design handoff) ============ */
:root { :root {
--bg-0:#16171a; /* editor surface (deepest) */ --bg-0:#16171a; /* editor surface (deepest) */
--bg-1:#1a1c1f; /* terminals */ --bg-1:#1a1c1f; /* terminals */
@@ -36,6 +36,10 @@
--t-prop:#6ec0c0; --t-prop:#6ec0c0;
--ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; --ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
/* code surfaces (editor + terminals) — overridable from .helder/theme.css */
--code-font:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
--code-size:13px;
--term-size:12.5px;
} }
* { box-sizing:border-box; } * { box-sizing:border-box; }
@@ -182,7 +186,7 @@ body {
.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; } .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-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; } .editor { flex:1; overflow:auto; font-family:var(--code-font); font-size:var(--code-size); line-height:20px; padding:6px 0 40px; }
.ln-row { display:flex; align-items:flex-start; min-height:20px; } .ln-row { display:flex; align-items:flex-start; min-height:20px; }
.ln-row.cursor { background:rgba(255,255,255,0.035); } .ln-row.cursor { background:rgba(255,255,255,0.035); }
.ln-row.add { background:var(--add-bg); } .ln-row.add { background:var(--add-bg); }
@@ -320,6 +324,7 @@ body {
.sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; } .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 { 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:hover { background:var(--hover); }
.sr-line.sel { background:var(--accent-soft); }
.sr-line .no { color:var(--fg-3); min-width:34px; text-align:right; } .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 .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; } .sr-line mark { background:rgba(216,168,92,.28); color:var(--fg-0); border-radius:2px; }
@@ -344,7 +349,8 @@ body {
.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 { 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); } .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); } /* 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 { 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: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 .kc { margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); }
@@ -370,3 +376,42 @@ body {
.sb.spacer { flex:1; } .sb.spacer { flex:1; }
.sb .a{color:var(--add);} .sb .d{color:var(--del);} .sb .a{color:var(--add);} .sb .d{color:var(--del);}
.sb b { font-weight:600; color:var(--fg-1); } .sb b { font-weight:600; color:var(--fg-1); }
/* editable buffer — transparent textarea over a highlighted <pre>, synced gutter */
.code-edit { flex:1; min-height:0; display:flex; overflow:hidden; }
.ce-gutterwrap { flex:0 0 54px; overflow:hidden; position:relative; }
.ce-gutter { padding-top:6px; will-change:transform; }
.ce-gutter div { height:20px; line-height:20px; text-align:right; padding-right:14px; color:var(--fg-3); font-family:var(--code-font); font-size:12px; user-select:none; }
.ce-scroll { flex:1; min-width:0; overflow:auto; position:relative; }
.ce-inner { position:relative; width:max-content; min-width:100%; }
.ce-pre, .ce-ta {
margin:0; padding:6px 16px 40px 6px; border:0;
font-family:var(--code-font); font-size:var(--code-size); line-height:20px;
white-space:pre; tab-size:4; -moz-tab-size:4; letter-spacing:0;
}
.ce-pre { display:block; pointer-events:none; color:var(--fg-0); }
.ce-ta {
position:absolute; inset:0; resize:none; outline:none; overflow:hidden;
background:transparent; color:transparent; caret-color:var(--accent);
}
.ce-ta::selection { background:rgba(77,141,255,0.32); }
/* xterm.js host (real terminals) */
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:6px 4px 6px 8px; background:var(--bg-1); }
.term-xterm .xterm { height:100%; }
.term-xterm .xterm-viewport { background:transparent !important; }
/* ============ Electron chrome integration ============ */
/* macOS shows native traffic lights (titleBarStyle: hiddenInset); the prototype's
decorative dots are hidden and the bar is made draggable. Interactive controls
opt back out of the drag region. */
.titlebar { -webkit-app-region: drag; padding-left: 82px; }
.titlebar .traffic { display: none; }
.titlebar button,
.titlebar input,
.titlebar textarea,
.titlebar .tb-actions { -webkit-app-region: no-drag; }
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
}

View File

@@ -0,0 +1,113 @@
/* Real terminals: xterm.js in the renderer bound to a node-pty PTY in main.
* Agent pane = a shell that auto-launches `claude`; bottom pane = a plain shell.
* "Pass on to Agent" arrives via the `agentPaste` window event and is written to
* the agent PTY wrapped in bracketed paste (\x1b[200~ … \x1b[201~) so the CLI
* treats it as pasted, UNSUBMITTED input — leaving the caret on a fresh line so
* references can be stacked before the user hits Enter. */
import React, { useEffect, useRef, useState } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
let _lid = 0
export const lid = (): number => ++_lid
const MONO = '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
// ANSI palette mapped onto Helder's charcoal tokens.
const THEME = {
background: '#1a1c1f',
foreground: '#e6e8ea',
cursor: '#4d8dff',
cursorAccent: '#1a1c1f',
selectionBackground: 'rgba(77,141,255,0.32)',
black: '#16171a', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c',
blue: '#4d8dff', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b4bac2',
brightBlack: '#5d636c', brightRed: '#e0696a', brightGreen: '#5cbd6b', brightYellow: '#d8a85c',
brightBlue: '#6aa6f0', brightMagenta: '#c98bdb', brightCyan: '#6ec0c0', brightWhite: '#e6e8ea',
}
export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement {
const hostRef = useRef<HTMLDivElement>(null)
const [live, setLive] = useState(kind === 'agent')
useEffect(() => {
const bridge = window.helder
const host = hostRef.current
if (!host) return
const css = getComputedStyle(document.documentElement)
const fontFamily = css.getPropertyValue('--code-font').trim() || MONO
const fontSize = parseFloat(css.getPropertyValue('--term-size')) || 12.5
const term = new XTerm({
fontFamily,
fontSize,
lineHeight: 1.4,
cursorBlink: true,
theme: THEME,
scrollback: 5000,
allowProposedApi: true,
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(host)
try { fit.fit() } catch { /* host not measured yet */ }
let disposed = false
let id = -1
let offData = (): void => {}
let offExit = (): void => {}
function onPaste(e: Event): void {
if (kind !== 'agent' || !bridge || id < 0) return
const text = (e as CustomEvent<string>).detail
bridge.pty.write(id, '\x1b[200~' + text + '\n\x1b[201~')
term.focus()
}
if (bridge) {
bridge.pty.create(kind, term.cols, term.rows).then((newId) => {
if (disposed) { if (newId >= 0) bridge.pty.kill(newId); return }
id = newId
if (id < 0) {
term.write('\r\n \x1b[33mPTY unavailable\x1b[0m — run `npm run rebuild`, then restart.\r\n')
setLive(false)
return
}
offData = bridge.pty.onData((tid, data) => { if (tid === id) term.write(data) })
offExit = bridge.pty.onExit((tid) => { if (tid === id) { term.write('\r\n\x1b[90m[process exited]\x1b[0m\r\n'); setLive(false) } })
term.onData((d) => bridge.pty.write(id, d))
term.onResize(({ cols, rows }) => bridge.pty.resize(id, cols, rows))
if (kind === 'agent') window.addEventListener('agentPaste', onPaste)
})
} else {
term.write(' \x1b[90mTerminal needs the Electron host (node-pty); not available in browser preview.\x1b[0m\r\n')
setLive(false)
}
const ro = new ResizeObserver(() => { try { fit.fit() } catch { /* noop */ } })
ro.observe(host)
return () => {
disposed = true
ro.disconnect()
offData()
offExit()
if (kind === 'agent') window.removeEventListener('agentPaste', onPaste)
if (bridge && id >= 0) bridge.pty.kill(id)
term.dispose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="term-pane" style={{ flex: 1, minHeight: 0 }} onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}>
<div className="term-head">
<span className={'dot' + (live ? ' live' : '')}></span>
<span className="lbl">{kind === 'agent' ? 'claude' : 'zsh'}</span>
<span className="tag">{kind === 'agent' ? 'agent session' : '— shell'}</span>
</div>
<div className="term-xterm" ref={hostRef} />
</div>
)
}

81
src/renderer/src/types.ts Normal file
View File

@@ -0,0 +1,81 @@
export type GitStatus = 'A' | 'M' | 'D' | 'R' | 'U'
export interface FileNode {
name: string
type: 'dir' | 'file'
path: string
open?: boolean
children?: FileNode[]
}
export interface DiffRow {
sign: string
oldNo: number | null
newNo: number | null
text: string
}
export interface SideLine {
no: number
text: string
mark?: 'add' | 'del' | null
}
export interface SplitRow {
l: SideLine | null
r: SideLine | null
}
export interface Diff {
rows: DiffRow[]
left: SideLine[]
right: SideLine[]
split: SplitRow[]
add: number
del: number
deleted: boolean
added: boolean
original: string
updated: string
}
export interface Change {
path: string
status: GitStatus
add: number
del: number
deleted: boolean
}
export interface Project {
name: string
branch: string
files: Record<string, string>
originals: Record<string, string>
tree: FileNode
diffs: Record<string, Diff>
changes: Change[]
}
/** A line descriptor consumed by the editor PaneView. */
export interface ViewLine {
no: number | null
text: string
sign?: string
row?: 'add' | 'del' | 'bar-add' | 'bar-del' | null
}
/** Effective project settings (mirrors src/main/config.ts). */
export interface HelderConfig {
ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number }
git: { confirmDiscard: boolean }
terminal: { shell: string | null }
}
export const DEFAULT_CONFIG: HelderConfig = {
ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 },
git: { confirmDiscard: true },
terminal: { shell: null },
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}

18
tsconfig.node.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["node", "electron-vite/node"],
"strict": true,
"noUnusedLocals": false,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/main/**/*", "src/preload/**/*", "electron.vite.config.ts"]
}

21
tsconfig.web.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["node"],
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/renderer/**/*"]
}