daf5b7be92
- Changes panel: a working file opens in a CodeMirror editor with its diff inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the unchanged lines, Whole file shows them; side by side at 50/50 and full width; a Revert per block; original from HEAD or the branch's merge base (`mergeBase` on GET /git/changes?range=branch) - Every diff: green = line added whole, red = removed whole, blue = edited in place (--diff-mod) - History drawn as a commit graph (#58: `parents` on GET /git/log, --date-order; lib/commitGraph.ts) - Per-workspace never-stage list, drawn orange in the Files tree (--kept); closed Terminal rows past three deleted - Status bar plan quota for Claude (per account) and Codex (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that stays open, Refresh button with a spinner - Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies; compact sidebar navigation and short session times
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
/**
|
|
* @file The Changes panel's file editor: CodeMirror 6 diffed against the
|
|
* file's original text (HEAD, or a branch's merge base), editable while the
|
|
* diff re-colours as you type. `unified` is one editor with the removed lines
|
|
* inline above their replacement (`unifiedMergeView`); `split` is the
|
|
* original read-only on the left beside the editable file (`MergeView`).
|
|
* `collapse` folds unchanged stretches away — the panel's Changes view —
|
|
* and every changed block carries a Revert that puts the original back in the
|
|
* editor (nothing is written until Save). Green marks a line added whole, red
|
|
* a line removed whole, blue a line edited in place (it keeps some of its
|
|
* text), its edit in a stronger blue — the same scheme as the read-only diff in
|
|
* LaneDiffView. Syntax colours come from the dashboard's own per-line
|
|
* tokenizer (`lib/highlight.ts`), like every other code view here.
|
|
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
|
* CodeMirror stays out of the main bundle, and one mount is one view.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
import { useEffect, useRef } from "react";
|
|
import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
|
import {
|
|
Decoration,
|
|
EditorView,
|
|
ViewPlugin,
|
|
drawSelection,
|
|
highlightActiveLine,
|
|
highlightActiveLineGutter,
|
|
keymap,
|
|
lineNumbers,
|
|
type DecorationSet,
|
|
type ViewUpdate,
|
|
} from "@codemirror/view";
|
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
|
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
|
|
import { highlight, tokenClass } from "../../lib/highlight";
|
|
|
|
/**
|
|
* True when a letter or digit of a line (`text`, starting at document offset
|
|
* `from`) lies outside every changed range: an edit inside the line, not a
|
|
* line added or removed whole. Kept indentation or punctuation alone (a `;`,
|
|
* a `}`) does not make two lines the same line.
|
|
*/
|
|
export function keepsText(text: string, from: number, changed: [number, number][]): boolean {
|
|
for (let i = 0; i < text.length; i += 1) {
|
|
if (!/\w/.test(text[i]!)) continue;
|
|
const at = from + i;
|
|
if (!changed.some(([a, b]) => at >= a && at < b)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const modLine = Decoration.line({ class: "cm-ah-mod" });
|
|
const marks = new Map<string, Decoration>();
|
|
const mark = (cls: string) => {
|
|
let d = marks.get(cls);
|
|
if (!d) marks.set(cls, (d = Decoration.mark({ class: cls })));
|
|
return d;
|
|
};
|
|
|
|
/** Syntax marks and the edited-in-place class for the visible lines — on
|
|
* either side of a split view (`a` is the original). */
|
|
function decorate(view: EditorView, lang: string): DecorationSet {
|
|
const out = new RangeSetBuilder<Decoration>();
|
|
const info = getChunks(view.state);
|
|
const a = info?.side === "a";
|
|
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
|
|
const chunks = info?.chunks ?? [];
|
|
const doc = view.state.doc;
|
|
let last = 0;
|
|
for (const { from, to } of view.visibleRanges) {
|
|
for (let pos = from; pos <= to; ) {
|
|
const line = doc.lineAt(pos);
|
|
pos = line.to + 1;
|
|
if (line.number <= last) continue;
|
|
last = line.number;
|
|
const c = chunks.find((k) => {
|
|
const [from, to, end] = span(k);
|
|
return to! > from! && line.from >= from! && line.from <= end!;
|
|
});
|
|
const at0 = c ? span(c)[0]! : 0;
|
|
if (
|
|
c &&
|
|
keepsText(
|
|
line.text,
|
|
line.from,
|
|
c.changes.map((ch) =>
|
|
a ? [at0 + ch.fromA, at0 + ch.toA] : [at0 + ch.fromB, at0 + ch.toB]
|
|
)
|
|
)
|
|
) {
|
|
out.add(line.from, line.from, modLine);
|
|
}
|
|
let at = line.from;
|
|
for (const tok of highlight(line.text, lang)) {
|
|
const cls = tokenClass(tok.type);
|
|
if (cls && tok.text) out.add(at, at + tok.text.length, mark(cls));
|
|
at += tok.text.length;
|
|
}
|
|
}
|
|
}
|
|
return out.finish();
|
|
}
|
|
|
|
/** The removed lines are drawn by @codemirror/merge's own widget, so they are
|
|
* classed after the fact, by `keepsText`'s rule: text outside its
|
|
* `.cm-deletedText` spans is text the line kept. */
|
|
function classDeleted(root: HTMLElement) {
|
|
root.querySelectorAll<HTMLElement>(".cm-deletedLine:not([data-ah])").forEach((line) => {
|
|
let kept = "";
|
|
(line.firstElementChild ?? line).childNodes.forEach((n) => {
|
|
if (!(n instanceof Element && n.classList.contains("cm-deletedText"))) {
|
|
kept += n.textContent ?? "";
|
|
}
|
|
});
|
|
line.dataset.ah = /\w/.test(kept) ? "mod" : "del";
|
|
});
|
|
}
|
|
|
|
const diffColours = (lang: string) =>
|
|
ViewPlugin.fromClass(
|
|
class {
|
|
decorations: DecorationSet;
|
|
constructor(view: EditorView) {
|
|
this.decorations = decorate(view, lang);
|
|
this.classLater(view);
|
|
}
|
|
update(u: ViewUpdate) {
|
|
if (u.docChanged || u.viewportChanged) this.decorations = decorate(u.view, lang);
|
|
this.classLater(u.view);
|
|
}
|
|
// After CodeMirror has drawn, and before the browser paints.
|
|
classLater(view: EditorView) {
|
|
view.requestMeasure({
|
|
key: this,
|
|
read: () => null,
|
|
write: () => classDeleted(view.contentDOM),
|
|
});
|
|
}
|
|
},
|
|
{ decorations: (p) => p.decorations }
|
|
);
|
|
|
|
const GREEN = "rgb(var(--status-success) / 0.18)";
|
|
const RED = "rgb(var(--status-danger) / 0.18)";
|
|
const BLUE = "rgb(var(--diff-mod) / 0.18)";
|
|
const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
|
|
// `&.cm-merge-b.cm-merge-b`: the doubled class outranks the merge package's
|
|
// base theme, whose selectors carry an extra light/dark scope class.
|
|
const M = "&.cm-merge-b.cm-merge-b";
|
|
const A = "&.cm-merge-a.cm-merge-a";
|
|
const theme = EditorView.theme({
|
|
"&": {
|
|
height: "100%",
|
|
fontSize: "11px",
|
|
backgroundColor: "rgb(var(--surface-0))",
|
|
color: "rgb(var(--fg-primary))",
|
|
},
|
|
"&.cm-focused": { outline: "none" },
|
|
".cm-scroller": {
|
|
fontFamily: '"JetBrains Mono", "Fira Code", Consolas, monospace',
|
|
lineHeight: "1.45",
|
|
},
|
|
".cm-gutters": {
|
|
backgroundColor: "rgb(var(--surface-1))",
|
|
color: "rgb(var(--fg-muted))",
|
|
border: "none",
|
|
},
|
|
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
|
|
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
|
|
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
|
|
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
|
backgroundColor: "rgb(var(--accent) / 0.3)",
|
|
},
|
|
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
|
|
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
|
[`${M} .cm-changedText`]: { background: "none" },
|
|
[`${M} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
|
|
[`${M} .cm-deletedChunk`]: { backgroundColor: "transparent" },
|
|
[`${M} .cm-deletedLine`]: { backgroundColor: RED },
|
|
[`${M} .cm-deletedLine[data-ah=mod]`]: { backgroundColor: BLUE },
|
|
[`${M} .cm-deletedChunk .cm-deletedText`]: { background: "none" },
|
|
[`${M} .cm-deletedLine[data-ah=mod] .cm-deletedText`]: { background: BLUE_EDIT },
|
|
[`${M} .cm-changedLineGutter`]: { background: "rgb(var(--status-success))" },
|
|
[`${M} .cm-deletedLineGutter`]: { background: "rgb(var(--status-danger))" },
|
|
// The original, left of a split view: its changed lines are removals.
|
|
[`${A} .cm-changedLine`]: { backgroundColor: RED },
|
|
[`${A} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
|
[`${A} .cm-changedText`]: { background: "none" },
|
|
[`${A} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
|
|
[`${A} .cm-changedLineGutter`]: { background: "rgb(var(--status-danger))" },
|
|
"&.cm-editor.cm-editor .cm-collapsedLines": {
|
|
color: "rgb(var(--fg-muted))",
|
|
background: "rgb(var(--surface-2))",
|
|
},
|
|
".cm-chunkButtons": { zIndex: "1" },
|
|
});
|
|
|
|
/** The Revert button on a changed block; @codemirror/merge wires the click.
|
|
* Between split columns there is room for an arrow only (`→`: left onto right). */
|
|
function revertButton(label: string, arrow = false) {
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.textContent = arrow ? "→" : label;
|
|
b.title = label;
|
|
b.setAttribute("aria-label", label);
|
|
b.className =
|
|
"rounded border border-border-light bg-surface-2 px-1.5 text-[10px] leading-4 text-fg-secondary hover:text-fg-primary";
|
|
return b;
|
|
}
|
|
|
|
export default function DiffEditor({
|
|
value,
|
|
original,
|
|
lang,
|
|
layout = "unified",
|
|
collapse = false,
|
|
label,
|
|
revertLabel,
|
|
onChange,
|
|
onSave,
|
|
}: {
|
|
value: string;
|
|
/** The text the diff is taken against; read once, at mount. */
|
|
original: string;
|
|
lang: string;
|
|
/** One editor with removals inline, or the original beside the file. */
|
|
layout?: "unified" | "split";
|
|
/** Fold unchanged stretches away: only the changes and their context. */
|
|
collapse?: boolean;
|
|
label: string;
|
|
/** The text of each changed block's Revert button. */
|
|
revertLabel: string;
|
|
onChange: (text: string) => void;
|
|
onSave: () => void;
|
|
}) {
|
|
const host = useRef<HTMLDivElement>(null);
|
|
const view = useRef<EditorView | null>(null);
|
|
const cb = useRef({ onChange, onSave });
|
|
cb.current = { onChange, onSave };
|
|
|
|
useEffect(() => {
|
|
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
|
|
const common = [lineNumbers(), diffColours(lang), theme];
|
|
const editing = [
|
|
...common,
|
|
highlightActiveLineGutter(),
|
|
history(),
|
|
drawSelection(),
|
|
highlightActiveLine(),
|
|
keymap.of([
|
|
{
|
|
key: "Mod-s",
|
|
preventDefault: true,
|
|
run: () => {
|
|
cb.current.onSave();
|
|
return true;
|
|
},
|
|
},
|
|
indentWithTab,
|
|
...defaultKeymap,
|
|
...historyKeymap,
|
|
]),
|
|
EditorView.contentAttributes.of({ "aria-label": label }),
|
|
EditorView.updateListener.of((u) => {
|
|
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
|
|
}),
|
|
];
|
|
let v: EditorView;
|
|
let destroy: () => void;
|
|
if (layout === "split") {
|
|
const mv = new MergeView({
|
|
parent: host.current!,
|
|
a: {
|
|
doc: original,
|
|
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
|
},
|
|
b: { doc: value, extensions: editing },
|
|
revertControls: "a-to-b",
|
|
renderRevertControl: () => revertButton(revertLabel, true),
|
|
collapseUnchanged: folded,
|
|
});
|
|
v = mv.b;
|
|
destroy = () => mv.destroy();
|
|
} else {
|
|
v = new EditorView({
|
|
parent: host.current!,
|
|
state: EditorState.create({
|
|
doc: value,
|
|
extensions: [
|
|
...editing,
|
|
unifiedMergeView({
|
|
original,
|
|
syntaxHighlightDeletions: false,
|
|
collapseUnchanged: folded,
|
|
// Accept would only hide a block; Revert is the one that edits.
|
|
mergeControls: (type, action) => {
|
|
const b = revertButton(revertLabel);
|
|
if (type === "accept") b.hidden = true;
|
|
else b.onmousedown = action;
|
|
return b;
|
|
},
|
|
}),
|
|
],
|
|
}),
|
|
});
|
|
destroy = () => v.destroy();
|
|
}
|
|
v.focus();
|
|
view.current = v;
|
|
return destroy;
|
|
// One mount per view: the parent keys this component by file and shape.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// A value set from outside (Cancel's revert, a save's fresh text).
|
|
useEffect(() => {
|
|
const v = view.current;
|
|
if (v && v.state.doc.toString() !== value) {
|
|
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
|
|
}
|
|
}, [value]);
|
|
|
|
return (
|
|
<div
|
|
ref={host}
|
|
data-testid="lane-file-editor"
|
|
data-layout={layout}
|
|
className="min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
|
|
/>
|
|
);
|
|
}
|