first commit

This commit is contained in:
2026-06-15 09:43:01 +02:00
commit bb0e497473
12 changed files with 3010 additions and 0 deletions

View File

@@ -0,0 +1,272 @@
/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */
function climbToLine(node) {
let el = node && node.nodeType === 3 ? node.parentElement : node;
while (el && !(el.dataset && el.dataset.line)) el = el.parentElement;
return el || null;
}
function EditorTabs({ tabs, active, onActivate, onClose }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current && ref.current.querySelector(".tab.active");
if (el) el.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [active]);
return (
<div className="tabs" ref={ref}>
{tabs.map((t) => {
const name = t.path.split("/").pop();
return (
<div key={t.path}
className={"tab" + (active === t.path ? " active" : "")}
onClick={() => onActivate(t.path)}
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path); } }}
title={t.path}>
<FileIcon path={t.path} />
<span className="tname">{name}</span>
{t.changed && <span className="tab-mode">{t.modeLabel}</span>}
<span className="tclose" onClick={(e) => { e.stopPropagation(); onClose(t.path); }}>
{Icon.close({})}
</span>
</div>
);
})}
</div>
);
}
/* Generic pane: renders an array of line descriptors with selection + caret + context. */
function PaneView({ cacheKey, path, lines, lang, showSign, refLine, cursor, selection, setCursor, setSelection, onContext }) {
const anchorRef = useRef(null);
const html = useMemo(() => lines.map((l) => HL.hlLine(l.text, lang)), [cacheKey]);
function gutterClick(e, no) {
if (no == null) return;
e.stopPropagation();
if (e.shiftKey && anchorRef.current != null) {
const a = anchorRef.current;
setSelection({ path, start: Math.min(a, no), end: Math.max(a, no), anchor: a });
} else {
anchorRef.current = no;
setSelection({ path, start: no, end: no, anchor: no });
}
setCursor({ path, line: no, col: 1 });
}
function caretCol(sel) {
try {
const el = climbToLine(sel.focusNode);
const code = el.querySelector(".ln-code");
const r = document.createRange();
r.setStart(code, 0); r.setEnd(sel.focusNode, sel.focusOffset);
return r.toString().length + 1;
} catch (e) { return 1; }
}
function onMouseUp() {
const sel = window.getSelection();
if (sel && !sel.isCollapsed) {
const a = climbToLine(sel.anchorNode), f = climbToLine(sel.focusNode);
if (a && f) {
const an = +a.dataset.line, fn = +f.dataset.line;
const s = Math.min(an, fn), e = Math.max(an, fn);
if (s !== e) { setSelection({ path, start: s, end: e, anchor: an }); setCursor({ path, line: fn, col: caretCol(sel) }); return; }
}
}
if (sel && sel.focusNode) {
const el = climbToLine(sel.focusNode);
if (el) { setCursor({ path, line: +el.dataset.line, col: caretCol(sel) }); setSelection(null); }
}
}
function handleContext(e) {
e.preventDefault();
const sel = window.getSelection();
let info = { path, kind: "editor" };
const a = sel && sel.anchorNode && climbToLine(sel.anchorNode);
const f = sel && sel.focusNode && climbToLine(sel.focusNode);
if (sel && !sel.isCollapsed && a && f && +a.dataset.line !== +f.dataset.line) {
const s = Math.min(+a.dataset.line, +f.dataset.line), en = Math.max(+a.dataset.line, +f.dataset.line);
info.sel = { start: s, end: en }; info.line = s;
} else if (selection && selection.path === path && selection.start !== selection.end) {
info.sel = { start: selection.start, end: selection.end }; info.line = selection.start;
} else {
let no = null;
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
const el = r && climbToLine(r.startContainer);
if (el) no = +el.dataset.line;
info.line = no || (cursor && cursor.path === path ? cursor.line : 1);
}
setCursor({ path, line: info.line, col: 1 });
onContext(e, info);
}
const curLine = cursor && cursor.path === path ? cursor.line : -1;
const sel = selection && selection.path === path ? selection : null;
return (
<div className={"editor" + (showSign ? " diff" : "")} onMouseUp={onMouseUp} onContextMenu={handleContext}>
{lines.map((l, i) => {
const no = l.no;
const inSel = sel && no != null && no >= sel.start && no <= sel.end;
const cls = "ln-row"
+ (l.row === "add" ? " add" : l.row === "del" ? " del" : "")
+ (l.row === "bar-add" ? " bar-add" : l.row === "bar-del" ? " bar-del" : "")
+ (no === curLine && !inSel && !l.row ? " cursor" : "")
+ (inSel ? " selrange" : "");
return (
<div key={i} data-line={no == null ? undefined : no} className={cls}>
<span className="ln-gutter" onClick={(e) => gutterClick(e, no)}>{no == null ? "" : no}</span>
{showSign && <span className="ln-sign">{l.sign === " " || !l.sign ? "" : l.sign}</span>}
<span className="ln-code" dangerouslySetInnerHTML={{ __html: html[i] }} />
</div>
);
})}
</div>
);
}
/* Build the line descriptors for a given mode. */
function buildLines(mode, diff, fileText) {
if (mode === "original") return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === "del" ? "bar-del" : null })), showSign: false };
if (mode === "updated") return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === "add" ? "bar-add" : null })), showSign: false };
if (mode === "diff") return { lines: diff.rows.map((r) => ({ no: r.newNo || r.oldNo, text: r.text, sign: r.sign, row: r.sign === "+" ? "add" : r.sign === "-" ? "del" : null })), showSign: true };
// plain file
const arr = (fileText || "").replace(/\n$/, "").split("\n");
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false };
}
const SEGMENTS = [
{ id: "original", label: "Original" },
{ id: "updated", label: "Updated" },
{ id: "diff", label: "Diff" },
];
function Editor({ tabs, active, mode, setMode, onActivate, onClose, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection }) {
const tab = tabs.find((t) => t.path === active);
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null;
const diff = tab ? PROJECT.diffs[tab.path] : null;
const lang = tab ? HL.langFor(tab.path) : null;
const effMode = change ? mode : "code";
let built = null;
if (tab) {
if (change && diff) built = buildLines(effMode, diff, PROJECT.files[tab.path]);
else built = buildLines("code", null, PROJECT.files[tab.path]);
}
const statusWord = change ? (change.status === "A" ? "Added" : change.status === "D" ? "Deleted" : "Modified") : "";
const activeSeg = splitOpen ? "split" : effMode;
const emptyUpdated = effMode === "updated" && built && built.lines.length === 0;
const emptyOriginal = effMode === "original" && built && built.lines.length === 0;
return (
<React.Fragment>
<EditorTabs tabs={tabs} active={active} onActivate={onActivate} onClose={onClose} />
{!tab ? (
<div className="empty-ed">
<div style={{ opacity: .5 }}>{Icon.file({ width: 30, height: 30 })}</div>
<div className="big">No file open</div>
<div className="klist">
<div><span>Search files & content</span><kbd> F</kbd></div>
<div><span>Copy reference</span><kbd>right-click</kbd></div>
<div><span>Pass on to Agent</span><kbd>right-click</kbd></div>
</div>
</div>
) : (
<div className="editor-wrap">
{change && (
<div className="diff-bar">
<span className={"git-stat " + change.status} style={{ width: "auto" }}>{statusWord}</span>
{change.add > 0 && <span className="a">+{change.add}</span>}
{change.del > 0 && <span className="d">{change.del}</span>}
<div className="seg">
{SEGMENTS.map((s) => (
<button key={s.id} className={activeSeg === s.id ? "on" : ""} onClick={() => setMode(s.id)}>{s.label}</button>
))}
<button className={"split-btn" + (activeSeg === "split" ? " on" : "")} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><rect x="1" y="1.5" width="10" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2"/><line x1="6" y1="1.5" x2="6" y2="10.5" stroke="currentColor" strokeWidth="1.2"/></svg>
Split
</button>
</div>
</div>
)}
{emptyUpdated ? (
<div className="empty-ed"><div className="big" style={{ color: "var(--del)" }}>No updated version</div><div style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--fg-3)" }}>This file was deleted in the change.</div></div>
) : emptyOriginal ? (
<div className="empty-ed"><div className="big" style={{ color: "var(--add)" }}>No original version</div><div style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--fg-3)" }}>This file is new in the change.</div></div>
) : (
<PaneView cacheKey={tab.path + ":" + effMode} path={tab.path} lines={built.lines}
lang={lang} showSign={built.showSign} cursor={cursor} selection={selection}
setCursor={setCursor} setSelection={setSelection} onContext={onContext} />
)}
</div>
)}
</React.Fragment>
);
}
/* Full-screen side-by-side split view */
function SplitView({ path, onClose, onContext }) {
const diff = PROJECT.diffs[path];
const lang = HL.langFor(path);
const leftRef = useRef(null), rightRef = useRef(null);
const lock = useRef(false);
const change = PROJECT.changes.find((c) => c.path === path);
const leftHtml = useMemo(() => diff.split.map((r) => r.l ? HL.hlLine(r.l.text, lang) : ""), [path]);
const rightHtml = useMemo(() => diff.split.map((r) => r.r ? HL.hlLine(r.r.text, lang) : ""), [path]);
function sync(from, to) {
if (lock.current) return; lock.current = true;
to.scrollTop = from.scrollTop; to.scrollLeft = from.scrollLeft;
requestAnimationFrame(() => { lock.current = false; });
}
function ctx(e, side) {
e.preventDefault();
let no = null;
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
const el = r && climbToLine(r.startContainer);
if (el) no = +el.dataset.line;
onContext(e, { path, kind: "editor", line: no || 1 });
}
return (
<div className="split-overlay">
<div className="split-head">
<FileIcon path={path} />
<span className="sh-name">{path}</span>
{change && <span className={"git-stat " + change.status} style={{ width: "auto" }}>{change.status === "A" ? "Added" : change.status === "D" ? "Deleted" : "Modified"}</span>}
{change && change.add > 0 && <span className="a" style={{ fontFamily: "var(--mono)", color: "var(--add)" }}>+{change.add}</span>}
{change && change.del > 0 && <span className="d" style={{ fontFamily: "var(--mono)", color: "var(--del)" }}>{change.del}</span>}
<button className="split-exit" onClick={onClose}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7 1.5h3.5V5M5 10.5H1.5V7M10.5 1.5L7 5M1.5 10.5L5 7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/></svg>
Collapse <kbd>Esc</kbd>
</button>
</div>
<div className="split-body">
<div className="split-pane left">
<div className="split-label">Original <span>before</span></div>
<div className="editor" ref={leftRef} onScroll={() => sync(leftRef.current, rightRef.current)} onContextMenu={(e) => ctx(e, "l")}>
{diff.split.map((row, i) => (
<div key={i} data-line={row.l ? row.l.no : undefined} className={"ln-row" + (row.l && row.l.mark === "del" ? " bar-del" : "") + (!row.l ? " empty" : "")}>
<span className="ln-gutter">{row.l ? row.l.no : ""}</span>
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.l ? leftHtml[i] : "" }} />
</div>
))}
</div>
</div>
<div className="split-pane right">
<div className="split-label">Updated <span>after</span></div>
<div className="editor" ref={rightRef} onScroll={() => sync(rightRef.current, leftRef.current)} onContextMenu={(e) => ctx(e, "r")}>
{diff.split.map((row, i) => (
<div key={i} data-line={row.r ? row.r.no : undefined} className={"ln-row" + (row.r && row.r.mark === "add" ? " bar-add" : "") + (!row.r ? " empty" : "")}>
<span className="ln-gutter">{row.r ? row.r.no : ""}</span>
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.r ? rightHtml[i] : "" }} />
</div>
))}
</div>
</div>
</div>
</div>
);
}
Object.assign(window, { Editor, EditorTabs, PaneView, SplitView, buildLines, climbToLine });