b7f3735b29
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
426 lines
16 KiB
TypeScript
426 lines
16 KiB
TypeScript
/**
|
|
* @file The file editor's side-by-side layout, JetBrains-shaped: the original
|
|
* (read-only, left) and the working file (editable, right) are two
|
|
* CodeMirror views in their OWN scrollers, with no padding to line them up —
|
|
* each shows its real lines — and the gutter between them draws, for every
|
|
* changed block, a curved band joining its lines on the left to its lines on
|
|
* the right (`lib/diffConnectors.ts`), with a » that reverts the block.
|
|
* Scrolling either side scrolls the other to the matching line (`mapLine`:
|
|
* 1:1 outside a block, proportional inside one).
|
|
*
|
|
* What @codemirror/merge's MergeView gave the old layout is kept, by its own
|
|
* public pieces: the chunks are `Chunk.build` at mount and `Chunk.updateB` on
|
|
* every edit of the right side; the changed-line, changed-text and gutter
|
|
* classes, the editor-root `cm-merge-a`/`cm-merge-b` classes, the folded
|
|
* unchanged stretches (opened on both sides at once) and the revert's text
|
|
* rule are ported from it, so DiffEditor's theme and colouring apply as before.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
import {
|
|
EditorState,
|
|
RangeSet,
|
|
StateEffect,
|
|
StateField,
|
|
Text,
|
|
type Extension,
|
|
} from "@codemirror/state";
|
|
import {
|
|
Decoration,
|
|
EditorView,
|
|
GutterMarker,
|
|
WidgetType,
|
|
gutterLineClass,
|
|
type DecorationSet,
|
|
} from "@codemirror/view";
|
|
import { Chunk, getChunks } from "@codemirror/merge";
|
|
import { bandPath, mapLine, type Span } from "../../lib/diffConnectors";
|
|
|
|
/** The chunks a side carries, the side it is, and (right side) the original. */
|
|
type SplitState = { chunks: readonly Chunk[]; side: "a" | "b"; orig: Text | null };
|
|
|
|
const setChunks = StateEffect.define<readonly Chunk[]>();
|
|
const uncollapse = StateEffect.define<number>();
|
|
|
|
/** Each side's chunks. The right side recomputes them as it is edited; the
|
|
* left, read-only, is handed the new list (`setChunks`). */
|
|
const splitField = StateField.define<SplitState>({
|
|
create: () => ({ chunks: [], side: "b", orig: null }),
|
|
update(v, tr) {
|
|
for (const e of tr.effects) if (e.is(setChunks)) return { ...v, chunks: e.value };
|
|
if (v.orig && tr.docChanged) {
|
|
return { ...v, chunks: Chunk.updateB(v.chunks, v.orig, tr.state.doc, tr.changes) };
|
|
}
|
|
return v;
|
|
},
|
|
});
|
|
|
|
/** The chunks and side of an editor in either merge layout — unified (the
|
|
* merge package's own state) or this split. */
|
|
export function chunksOf(
|
|
state: EditorState
|
|
): { chunks: readonly Chunk[]; side: "a" | "b" | null } | null {
|
|
return getChunks(state) ?? state.field(splitField, false) ?? null;
|
|
}
|
|
|
|
const changedLine = Decoration.line({ class: "cm-changedLine" });
|
|
const changedText = Decoration.mark({ class: "cm-changedText" });
|
|
const changedGutter = new (class extends GutterMarker {
|
|
override elementClass = "cm-changedLineGutter";
|
|
})();
|
|
|
|
/** The lines (and, inside them, the changed spans) of this side's chunks. */
|
|
function changedRanges(state: EditorState) {
|
|
const { chunks, side } = state.field(splitField);
|
|
const doc = state.doc;
|
|
const lines: number[] = [];
|
|
const spans: [number, number][] = [];
|
|
for (const c of chunks) {
|
|
const [from, to] = side === "a" ? [c.fromA, c.toA] : [c.fromB, c.toB];
|
|
if (from >= to) continue;
|
|
const last = doc.lineAt(Math.min(to - 1, doc.length)).number;
|
|
for (let n = doc.lineAt(from).number; n <= last; n += 1) {
|
|
const line = doc.line(n);
|
|
lines.push(line.from);
|
|
for (const ch of c.changes) {
|
|
const s = Math.max(line.from, from + (side === "a" ? ch.fromA : ch.fromB));
|
|
const e = Math.min(line.to, from + (side === "a" ? ch.toA : ch.toB));
|
|
if (s < e) spans.push([s, e]);
|
|
}
|
|
}
|
|
}
|
|
return { lines, spans };
|
|
}
|
|
|
|
const chunkDecorations = EditorView.decorations.compute([splitField], (state) => {
|
|
const { lines, spans } = changedRanges(state);
|
|
return Decoration.set(
|
|
[...lines.map((p) => changedLine.range(p)), ...spans.map(([s, e]) => changedText.range(s, e))],
|
|
true
|
|
);
|
|
});
|
|
const chunkGutter = gutterLineClass.compute([splitField], (state) =>
|
|
RangeSet.of(
|
|
changedRanges(state).lines.map((p) => changedGutter.range(p)),
|
|
true
|
|
)
|
|
);
|
|
|
|
/** The other side's editor, for a fold opened on one side to open on both. */
|
|
const siblings = new WeakMap<EditorView, EditorView>();
|
|
|
|
/** `pos` on one side, moved past the chunks before it to the other (ported). */
|
|
function mapPos(pos: number, chunks: readonly Chunk[], isA: boolean) {
|
|
let ours = 0;
|
|
let theirs = 0;
|
|
for (const c of chunks) {
|
|
if ((isA ? c.fromA : c.fromB) >= pos) break;
|
|
[ours, theirs] = isA ? [c.toA, c.toB] : [c.toB, c.toA];
|
|
}
|
|
return theirs + (pos - ours);
|
|
}
|
|
|
|
class CollapseWidget extends WidgetType {
|
|
constructor(readonly lines: number) {
|
|
super();
|
|
}
|
|
override eq(o: CollapseWidget) {
|
|
return this.lines === o.lines;
|
|
}
|
|
toDOM(view: EditorView) {
|
|
const el = document.createElement("div");
|
|
el.className = "cm-collapsedLines";
|
|
el.textContent = view.state.phrase("$ unchanged lines", this.lines);
|
|
el.addEventListener("click", (e) => {
|
|
const pos = view.posAtDOM(e.target as Node);
|
|
view.dispatch({ effects: uncollapse.of(pos) });
|
|
const { chunks, side } = view.state.field(splitField);
|
|
siblings.get(view)?.dispatch({ effects: uncollapse.of(mapPos(pos, chunks, side === "a")) });
|
|
});
|
|
return el;
|
|
}
|
|
override ignoreEvent(e: Event) {
|
|
return e instanceof MouseEvent;
|
|
}
|
|
override get estimatedHeight() {
|
|
return 27;
|
|
}
|
|
}
|
|
|
|
/** The folded unchanged stretches: built once at mount, then only mapped,
|
|
* opened by a click, and dropped where an edit grows a chunk into them. */
|
|
const collapsedField = StateField.define<DecorationSet>({
|
|
create: () => Decoration.none,
|
|
update(deco, tr) {
|
|
deco = deco.map(tr.changes);
|
|
for (const e of tr.effects) {
|
|
if (e.is(uncollapse)) deco = deco.update({ filter: (from) => from !== e.value });
|
|
}
|
|
const { chunks, side } = tr.state.field(splitField);
|
|
if (deco.size && chunks !== tr.startState.field(splitField).chunks) {
|
|
const touching: number[] = [];
|
|
for (const c of chunks) {
|
|
deco.between(side === "a" ? c.fromA : c.fromB, side === "a" ? c.toA : c.toB, (from) => {
|
|
touching.push(from);
|
|
});
|
|
}
|
|
if (touching.length) deco = deco.update({ filter: (from) => !touching.includes(from) });
|
|
}
|
|
return deco;
|
|
},
|
|
provide: (f) => EditorView.decorations.from(f),
|
|
});
|
|
|
|
function buildCollapsed(state: EditorState, margin: number, minLines: number) {
|
|
const { chunks, side } = state.field(splitField);
|
|
const doc = state.doc;
|
|
const out = [];
|
|
let prev = 1;
|
|
for (let i = 0; ; i += 1) {
|
|
const c = i < chunks.length ? chunks[i]! : null;
|
|
const from = i ? prev + margin : 1;
|
|
const to = c ? doc.lineAt(side === "a" ? c.fromA : c.fromB).number - 1 - margin : doc.lines;
|
|
const lines = to - from + 1;
|
|
if (lines >= minLines) {
|
|
out.push(
|
|
Decoration.replace({ widget: new CollapseWidget(lines), block: true }).range(
|
|
doc.line(from).from,
|
|
doc.line(to).to
|
|
)
|
|
);
|
|
}
|
|
if (!c) break;
|
|
prev = doc.lineAt(Math.min(doc.length, side === "a" ? c.toA : c.toB)).number;
|
|
}
|
|
return Decoration.set(out);
|
|
}
|
|
|
|
/** A chunk side as 0-based half-open lines; an empty side is the point
|
|
* before the line it sits above (past the end: after the last line). */
|
|
function sideLines(doc: Text, from: number, to: number): [number, number] {
|
|
const first = from > doc.length ? doc.lines : doc.lineAt(from).number - 1;
|
|
if (from >= to) return [first, first];
|
|
return [first, doc.lineAt(Math.min(to - 1, doc.length)).number];
|
|
}
|
|
|
|
function spansOf(chunks: readonly Chunk[], a: Text, b: Text): Span[] {
|
|
return chunks.map((c) => {
|
|
const [a0, a1] = sideLines(a, c.fromA, c.toA);
|
|
const [b0, b1] = sideLines(b, c.fromB, c.toB);
|
|
return { a0, a1, b0, b1 };
|
|
});
|
|
}
|
|
|
|
/** Where the document starts inside the view's scroll content. */
|
|
const docOffset = (v: EditorView) =>
|
|
v.documentTop - v.scrollDOM.getBoundingClientRect().top + v.scrollDOM.scrollTop;
|
|
|
|
/** The fractional line at the top of the view (a folded stretch spreads its
|
|
* height over the lines it hides). */
|
|
function topLine(v: EditorView) {
|
|
const h = Math.max(0, v.scrollDOM.scrollTop - docOffset(v));
|
|
const blk = v.lineBlockAtHeight(h);
|
|
const first = v.state.doc.lineAt(blk.from).number - 1;
|
|
const n = v.state.doc.lineAt(blk.to).number - first;
|
|
const frac = blk.height > 0 ? Math.min(1, Math.max(0, (h - blk.top) / blk.height)) : 0;
|
|
return first + frac * n;
|
|
}
|
|
|
|
/** Document y of a fractional line (0-based). */
|
|
function yOf(v: EditorView, line: number) {
|
|
const doc = v.state.doc;
|
|
if (line >= doc.lines) return v.lineBlockAt(doc.length).bottom;
|
|
const blk = v.lineBlockAt(doc.line(Math.max(0, Math.floor(line)) + 1).from);
|
|
const first = doc.lineAt(blk.from).number - 1;
|
|
const n = doc.lineAt(blk.to).number - first;
|
|
return blk.top + ((line - first) / n) * blk.height;
|
|
}
|
|
|
|
/** The gutter's width: room for an S-curve and the » under it. */
|
|
const GUTTER_PX = 40;
|
|
const BAND: Record<"add" | "del" | "mod", string> = {
|
|
add: "fill-status-success/20 stroke-status-success/60",
|
|
del: "fill-status-danger/20 stroke-status-danger/60",
|
|
mod: "fill-diff-mod/20 stroke-diff-mod/60",
|
|
};
|
|
const SVG = "http://www.w3.org/2000/svg";
|
|
|
|
export function splitDiff({
|
|
parent,
|
|
original,
|
|
value,
|
|
extA,
|
|
extB,
|
|
collapse,
|
|
revertLabel,
|
|
}: {
|
|
parent: HTMLElement;
|
|
original: string;
|
|
value: string;
|
|
/** Each side's own extensions (DiffEditor's colours, keymaps, …). */
|
|
extA: Extension[];
|
|
extB: Extension[];
|
|
collapse?: { margin: number; minSize: number };
|
|
revertLabel: string;
|
|
}): { a: EditorView; b: EditorView; destroy: () => void } {
|
|
const origDoc = Text.of(original.split(/\r?\n/));
|
|
const chunks0 = Chunk.build(origDoc, Text.of(value.split(/\r?\n/)));
|
|
|
|
const root = parent.appendChild(document.createElement("div"));
|
|
root.className = "flex h-full min-h-0";
|
|
const pane = () => {
|
|
const el = document.createElement("div");
|
|
el.className = "h-full min-w-0 flex-1";
|
|
return el;
|
|
};
|
|
const paneA = root.appendChild(pane());
|
|
const gutter = root.appendChild(document.createElement("div"));
|
|
gutter.className =
|
|
"cm-merge-revert relative h-full shrink-0 overflow-hidden border-x border-border-light bg-surface-1";
|
|
gutter.style.width = `${GUTTER_PX}px`;
|
|
gutter.dataset.testid = "lane-editor-connectors";
|
|
const svg = gutter.appendChild(document.createElementNS(SVG, "svg"));
|
|
svg.setAttribute("class", "absolute inset-0 h-full w-full");
|
|
const buttons = gutter.appendChild(document.createElement("div"));
|
|
const paneB = root.appendChild(pane());
|
|
|
|
let frame = 0;
|
|
const draw = () => {
|
|
if (frame) return;
|
|
const run = () => {
|
|
frame = 0;
|
|
paint();
|
|
};
|
|
frame =
|
|
typeof requestAnimationFrame === "function"
|
|
? requestAnimationFrame(run)
|
|
: window.setTimeout(run);
|
|
};
|
|
|
|
const side = (s: "a" | "b"): Extension[] => [
|
|
splitField.init(() => ({ chunks: chunks0, side: s, orig: s === "b" ? origDoc : null })),
|
|
chunkDecorations,
|
|
chunkGutter,
|
|
EditorView.editorAttributes.of({ class: `cm-merge-${s}` }),
|
|
...(collapse
|
|
? [collapsedField.init((st) => buildCollapsed(st, collapse.margin, collapse.minSize))]
|
|
: []),
|
|
EditorView.updateListener.of((u) => {
|
|
if (u.docChanged || u.geometryChanged || u.viewportChanged) draw();
|
|
}),
|
|
];
|
|
|
|
const a = new EditorView({
|
|
parent: paneA,
|
|
state: EditorState.create({ doc: origDoc, extensions: [...side("a"), ...extA] }),
|
|
});
|
|
const b = new EditorView({
|
|
parent: paneB,
|
|
state: EditorState.create({
|
|
doc: value,
|
|
extensions: [
|
|
...side("b"),
|
|
...extB,
|
|
// The left side is told the chunks the right side's edit produced.
|
|
EditorView.updateListener.of((u) => {
|
|
const now = u.state.field(splitField).chunks;
|
|
if (now !== u.startState.field(splitField).chunks)
|
|
a.dispatch({ effects: setChunks.of(now) });
|
|
}),
|
|
],
|
|
}),
|
|
});
|
|
siblings.set(a, b);
|
|
siblings.set(b, a);
|
|
|
|
const spans = () => spansOf(b.state.field(splitField).chunks, a.state.doc, b.state.doc);
|
|
|
|
// Scrolling one side scrolls the other to the line beside it. A scroll we
|
|
// caused comes back as the other side's own event: `echo` holds where we
|
|
// put it, so that event is recognised and not mirrored back.
|
|
const echo = { a: -1, b: -1 };
|
|
const follow = (from: "a" | "b") => () => {
|
|
const [src, dst, to] = from === "a" ? [a, b, "b" as const] : [b, a, "a" as const];
|
|
if (Math.abs(src.scrollDOM.scrollTop - echo[from]) < 1) {
|
|
echo[from] = -1;
|
|
draw();
|
|
return;
|
|
}
|
|
const line = mapLine(topLine(src), spans(), from === "a");
|
|
const target = Math.round(yOf(dst, line) + docOffset(dst));
|
|
const before = dst.scrollDOM.scrollTop;
|
|
dst.scrollDOM.scrollTop = target;
|
|
if (dst.scrollDOM.scrollTop !== before) echo[to] = dst.scrollDOM.scrollTop;
|
|
draw();
|
|
};
|
|
const onA = follow("a");
|
|
const onB = follow("b");
|
|
a.scrollDOM.addEventListener("scroll", onA, { passive: true });
|
|
b.scrollDOM.addEventListener("scroll", onB, { passive: true });
|
|
const resize = typeof ResizeObserver === "function" ? new ResizeObserver(draw) : null;
|
|
resize?.observe(root);
|
|
|
|
// » puts the original block back on the right (the merge package's rule:
|
|
// a non-empty source brings its line break unless it ends the document).
|
|
buttons.addEventListener("mousedown", (e) => {
|
|
const i = Number((e.target as HTMLElement).closest<HTMLElement>("[data-chunk]")?.dataset.chunk);
|
|
const c = b.state.field(splitField).chunks[i];
|
|
if (!c) return;
|
|
let insert = a.state.sliceDoc(c.fromA, Math.max(c.fromA, c.toA - 1));
|
|
if (c.fromA !== c.toA && c.toB <= b.state.doc.length) insert += a.state.lineBreak;
|
|
b.dispatch({
|
|
changes: { from: c.fromB, to: Math.min(b.state.doc.length, c.toB), insert },
|
|
userEvent: "revert",
|
|
});
|
|
e.preventDefault();
|
|
});
|
|
|
|
function paint() {
|
|
const g = gutter.getBoundingClientRect();
|
|
const height = gutter.clientHeight;
|
|
const ya = (l: number) => a.documentTop - g.top + yOf(a, l);
|
|
const yb = (l: number) => b.documentTop - g.top + yOf(b, l);
|
|
const paths: SVGPathElement[] = [];
|
|
const btns: HTMLButtonElement[] = [];
|
|
spans().forEach((s, i) => {
|
|
const [y0, y1, z0, z1] = [ya(s.a0), ya(s.a1), yb(s.b0), yb(s.b1)];
|
|
// Off screen on both sides: nothing to draw (no layout = draw all).
|
|
if (height > 0 && (Math.max(y1, z1) < 0 || Math.min(y0, z0) > height)) return;
|
|
const kind = s.a0 === s.a1 ? "add" : s.b0 === s.b1 ? "del" : "mod";
|
|
const p = document.createElementNS(SVG, "path");
|
|
p.setAttribute("d", bandPath(GUTTER_PX, y0, y1, z0, z1));
|
|
p.setAttribute("class", BAND[kind]);
|
|
p.setAttribute("vector-effect", "non-scaling-stroke");
|
|
p.dataset.kind = kind;
|
|
paths.push(p);
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.textContent = "»";
|
|
btn.title = revertLabel;
|
|
btn.setAttribute("aria-label", revertLabel);
|
|
btn.dataset.chunk = String(i);
|
|
btn.className =
|
|
"absolute left-0.5 z-10 rounded px-0.5 text-[11px] leading-none text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
|
btn.style.top = `${Math.max(0, y0) + 1}px`;
|
|
btns.push(btn);
|
|
});
|
|
svg.replaceChildren(...paths);
|
|
buttons.replaceChildren(...btns);
|
|
}
|
|
draw();
|
|
|
|
return {
|
|
a,
|
|
b,
|
|
destroy: () => {
|
|
if (frame)
|
|
(typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : clearTimeout)(frame);
|
|
resize?.disconnect();
|
|
a.scrollDOM.removeEventListener("scroll", onA);
|
|
b.scrollDOM.removeEventListener("scroll", onB);
|
|
a.destroy();
|
|
b.destroy();
|
|
root.remove();
|
|
},
|
|
};
|
|
}
|