init helder
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
.playwright-mcp/
|
||||||
28
CLAUDE.md
28
CLAUDE.md
@@ -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 1–7 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.
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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");
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
@@ -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 & 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;
|
|
||||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
|
||||||
}
|
|
||||||
|
|
||||||
// highlight a single line independently (keeps line numbering robust)
|
|
||||||
function hlLine(line, lang) {
|
|
||||||
if (line === "") return " ";
|
|
||||||
try {
|
|
||||||
const grammar = lang && window.Prism && Prism.languages[lang];
|
|
||||||
if (grammar) return Prism.highlight(line, grammar, lang);
|
|
||||||
} catch (e) { /* fall through */ }
|
|
||||||
return escapeHtml(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- file-type icon: colored monogram chip --------------------------
|
|
||||||
const ICONS = {
|
|
||||||
php: { c: "#a78bdb", t: "php" },
|
|
||||||
js: { c: "#e6c860", t: "js" },
|
|
||||||
mjs: { c: "#e6c860", t: "js" },
|
|
||||||
ts: { c: "#5a9bd6", t: "ts" },
|
|
||||||
tsx: { c: "#5a9bd6", t: "ts" },
|
|
||||||
jsx: { c: "#5a9bd6", t: "jsx" },
|
|
||||||
py: { c: "#5fa8d6", t: "py" },
|
|
||||||
html: { c: "#e08b6a", t: "<>" },
|
|
||||||
css: { c: "#5a9bd6", t: "{}" },
|
|
||||||
scss: { c: "#d6699e", t: "{}" },
|
|
||||||
json: { c: "#d8a85c", t: "{}" },
|
|
||||||
md: { c: "#9aa0a8", t: "md" },
|
|
||||||
env: { c: "#7fc6a0", t: "$" },
|
|
||||||
sh: { c: "#7fc6a0", t: "$" },
|
|
||||||
yml: { c: "#cf7a6a", t: "yml" },
|
|
||||||
yaml: { c: "#cf7a6a", t: "yml" },
|
|
||||||
lock: { c: "#8a8f98", t: "lk" },
|
|
||||||
};
|
|
||||||
const NAME_ICONS = {
|
|
||||||
"composer.json": { c: "#a78bdb", t: "co" },
|
|
||||||
"package.json": { c: "#cf7a6a", t: "pk" },
|
|
||||||
"README.md": { c: "#5a9bd6", t: "md" },
|
|
||||||
".env": { c: "#7fc6a0", t: "$" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function iconFor(path) {
|
|
||||||
const base = path.split("/").pop() || "";
|
|
||||||
if (NAME_ICONS[base]) return NAME_ICONS[base];
|
|
||||||
return ICONS[ext(path)] || { c: "#7d838c", t: base.slice(0, 2) || "·" };
|
|
||||||
}
|
|
||||||
|
|
||||||
window.HL = { ext, langFor, langLabel, hlLine, iconFor, escapeHtml };
|
|
||||||
})();
|
|
||||||
@@ -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;
|
|
||||||
@@ -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">></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 <file> git status git diff echo clear" },
|
|
||||||
]); return;
|
|
||||||
case "pwd": push({ kind: "t", html: "/Users/dev/console" }); return;
|
|
||||||
case "ls":
|
|
||||||
push({ kind: "t", html: "<span class='fp'>config</span> <span class='fp'>public</span> <span class='fp'>scripts</span> <span class='fp'>src</span> <span class='fp'>templates</span> composer.json package.json README.md" });
|
|
||||||
return;
|
|
||||||
case "echo": push({ kind: "t", html: HL.escapeHtml(arg) }); return;
|
|
||||||
case "git":
|
|
||||||
if (rest[0] === "status") {
|
|
||||||
pushMany([
|
|
||||||
{ kind: "t", cls: "dim", html: `On branch <span style='color:var(--fg-1)'>${PROJECT.branch}</span>` },
|
|
||||||
{ kind: "t", cls: "dim", html: "Changes to be committed:" },
|
|
||||||
...PROJECT.changes.map((ch) => ({
|
|
||||||
kind: "t",
|
|
||||||
html: ` <span style="color:var(--${ch.status === 'A' ? 'add' : ch.status === 'D' ? 'del' : 'mod'})">${ch.status === 'A' ? 'new file' : ch.status === 'D' ? 'deleted ' : 'modified'}</span> <span class="fp">${ch.path}</span>`,
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
} else if (rest[0] === "diff") {
|
|
||||||
pushMany([
|
|
||||||
{ kind: "t", cls: "dim", html: "diff --git a/public/assets/app.js b/public/assets/app.js" },
|
|
||||||
{ kind: "t", cls: "err", html: "- const res = await fetch('/api/session');" },
|
|
||||||
{ kind: "t", cls: "ok", html: "+ const res = await fetch('/api/session', { credentials: 'include' });" },
|
|
||||||
]);
|
|
||||||
} else push({ kind: "t", cls: "dim", html: `git: '${rest[0] || ""}' is not handled in this demo` });
|
|
||||||
return;
|
|
||||||
case "cat": {
|
|
||||||
const f = PROJECT.files[arg] || Object.entries(PROJECT.files).find(([p]) => p.endsWith(arg))?.[1];
|
|
||||||
if (!f) { push({ kind: "t", cls: "err", html: `cat: ${HL.escapeHtml(arg)}: No such file` }); return; }
|
|
||||||
pushMany(f.replace(/\n$/, "").split("\n").slice(0, 24).map((l) => ({ kind: "t", cls: "dim", html: HL.escapeHtml(l) || " " })));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
default: push({ kind: "t", cls: "err", html: `${HL.escapeHtml(c)}: command not found` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
const cmd = input.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">></span> ${HL.escapeHtml(cmd).replace(/\n/g, "<br> ") || " "}` });
|
|
||||||
input.value = "";
|
|
||||||
if (cmd) runAgent(cmd);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
push({ kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> ${HL.escapeHtml(cmd) || " "}` });
|
|
||||||
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">></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
24
electron.vite.config.ts
Normal 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
3357
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal 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
93
src/main/config.ts
Normal 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
117
src/main/fs-service.ts
Normal 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
108
src/main/git-service.ts
Normal 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
141
src/main/index.ts
Normal 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
45
src/main/project.ts
Normal 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
79
src/main/pty-service.ts
Normal 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()
|
||||||
|
}
|
||||||
87
src/main/search-service.ts
Normal file
87
src/main/search-service.ts
Normal 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
9
src/preload/index.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { HelderApi } from './index'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
helder: HelderApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
85
src/preload/index.ts
Normal file
85
src/preload/index.ts
Normal 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
12
src/renderer/index.html
Normal 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
356
src/renderer/src/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
240
src/renderer/src/components.tsx
Normal file
240
src/renderer/src/components.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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++; }
|
diffs[c.path] = Object.assign(d, {
|
||||||
}
|
deleted: c.status === 'D',
|
||||||
while (i < n) { ops.push({ t: "del", a: i++ }); }
|
added: c.status === 'A',
|
||||||
while (j < m) { ops.push({ t: "add", b: j++ }); }
|
original: orig,
|
||||||
|
updated: upd,
|
||||||
|
})
|
||||||
|
return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D' }
|
||||||
|
})
|
||||||
|
|
||||||
const rows = [], left = [], right = [], split = [];
|
export const PROJECT: Project = {
|
||||||
const delSet = new Set(), addSet = new Set();
|
name: 'console',
|
||||||
let add = 0, del = 0;
|
branch: 'feat/payments-balance',
|
||||||
for (const op of ops) {
|
files: F,
|
||||||
if (op.t === "same") {
|
originals: O,
|
||||||
rows.push({ sign: " ", oldNo: op.a + 1, newNo: op.b + 1, text: a[op.a] });
|
tree,
|
||||||
} else if (op.t === "del") {
|
diffs,
|
||||||
rows.push({ sign: "-", oldNo: op.a + 1, newNo: null, text: a[op.a] });
|
changes,
|
||||||
delSet.add(op.a); del++;
|
}
|
||||||
} else {
|
|
||||||
rows.push({ sign: "+", oldNo: null, newNo: op.b + 1, text: b[op.b] });
|
|
||||||
addSet.add(op.b); add++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.forEach((text, idx) => left.push({ no: idx + 1, text, mark: delSet.has(idx) ? "del" : null }));
|
|
||||||
b.forEach((text, idx) => right.push({ no: idx + 1, text, mark: addSet.has(idx) ? "add" : null }));
|
|
||||||
|
|
||||||
// aligned split rows (pair del/add blocks)
|
|
||||||
let dbuf = [], abuf = [];
|
|
||||||
const flush = () => {
|
|
||||||
const k = Math.max(dbuf.length, abuf.length);
|
|
||||||
for (let x = 0; x < k; x++) split.push({ l: dbuf[x] || null, r: abuf[x] || null });
|
|
||||||
dbuf = []; abuf = [];
|
|
||||||
};
|
|
||||||
for (const op of ops) {
|
|
||||||
if (op.t === "same") { flush(); split.push({ l: { no: op.a + 1, text: a[op.a] }, r: { no: op.b + 1, text: b[op.b] } }); }
|
|
||||||
else if (op.t === "del") dbuf.push({ no: op.a + 1, text: a[op.a], mark: "del" });
|
|
||||||
else abuf.push({ no: op.b + 1, text: b[op.b], mark: "add" });
|
|
||||||
}
|
|
||||||
flush();
|
|
||||||
|
|
||||||
return { rows, left, right, split, add, del };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- changed files -------------------------------------------------
|
|
||||||
const changeDefs = [
|
|
||||||
{ path: "src/Service/PaymentService.php", status: "A" },
|
|
||||||
{ path: "src/Http/Controller/UserController.php", status: "M" },
|
|
||||||
{ path: "public/assets/app.js", status: "M" },
|
|
||||||
{ path: "config/app.json", status: "M" },
|
|
||||||
{ path: "scripts/migrate.py", status: "M" },
|
|
||||||
{ path: "src/Model/LegacyUser.php", status: "D" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const diffs = {};
|
|
||||||
const changes = changeDefs.map((c) => {
|
|
||||||
const orig = O[c.path] != null ? O[c.path] : "";
|
|
||||||
const upd = F[c.path] != null ? F[c.path] : "";
|
|
||||||
const d = buildDiff(orig, upd);
|
|
||||||
diffs[c.path] = Object.assign(d, {
|
|
||||||
deleted: c.status === "D",
|
|
||||||
added: c.status === "A",
|
|
||||||
original: orig,
|
|
||||||
updated: upd,
|
|
||||||
});
|
|
||||||
return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === "D" };
|
|
||||||
});
|
|
||||||
|
|
||||||
window.PROJECT = {
|
|
||||||
name: "console",
|
|
||||||
branch: "feat/payments-balance",
|
|
||||||
files: F,
|
|
||||||
originals: O,
|
|
||||||
tree,
|
|
||||||
diffs,
|
|
||||||
changes,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
66
src/renderer/src/diff.ts
Normal file
66
src/renderer/src/diff.ts
Normal 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
389
src/renderer/src/editor.tsx
Normal 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 & 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
59
src/renderer/src/env.d.ts
vendored
Normal 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 {}
|
||||||
115
src/renderer/src/highlight.ts
Normal file
115
src/renderer/src/highlight.ts
Normal 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) => ({ '&': '&', '<': '<', '>': '>' }[c] as string))
|
||||||
|
}
|
||||||
|
|
||||||
|
// highlight a single line independently (keeps line numbering robust)
|
||||||
|
function hlLine(line: string, lang: string | null): string {
|
||||||
|
if (line === '') return ' '
|
||||||
|
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
23
src/renderer/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
267
src/renderer/src/overlays.tsx
Normal file
267
src/renderer/src/overlays.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
186
src/renderer/src/project.tsx
Normal file
186
src/renderer/src/project.tsx
Normal 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>
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
113
src/renderer/src/terminals.tsx
Normal file
113
src/renderer/src/terminals.tsx
Normal 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
81
src/renderer/src/types.ts
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.web.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
18
tsconfig.node.json
Normal file
18
tsconfig.node.json
Normal 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
21
tsconfig.web.json
Normal 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/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user