273 lines
13 KiB
JavaScript
273 lines
13 KiB
JavaScript
/* 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 });
|