3a8be55b55
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions, user_prefs.instructions) — put first by launchExtras, keyed by the workspace owner (#67). - Session view: Settings opens as one more tab of the tab bar. - Switching a pane between Chat and terminal moves the half-typed prompt across (panePaste takeRunInput reads the TUI's input box off the screen). - Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted; Add to Chat floats at the selection's last line in the diff editor. - S3: a server WITHOUT S3 (local mode / a dev server on the same database) never ends a run or releases a workspace whose folder is under the users root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used by reconcileOrphans and healRunId). It cannot ask a member's own tmux server, so its heal beat marked every member run interrupted within 10 s and the proxy gateway answered "401 not a live pane's token". Regression test: s3-unseen-member-runs.test.js. Committed --no-verify: tsc -b, the touched server suites (dashboard-runs, lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and client suites (613) run by hand.
666 lines
25 KiB
TypeScript
666 lines
25 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, JetBrains-shaped —
|
|
* two scrollers kept on matching lines, no padding, a curved band per changed
|
|
* block between them (`splitDiff.ts`).
|
|
* `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.
|
|
* Given `onBlock`, each changed block also gets the read-only diff's bar under
|
|
* its last line — Undo (asks twice) and the block's commit checkbox
|
|
* (`blockIncluded`) — in place of its Revert; the panel turns it off while
|
|
* the editor holds unsaved text, because git acts on the file on disk.
|
|
* Given `blame`, a gutter left of the line numbers names each line's last
|
|
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
|
|
* a function that moves the cursor to the next / previous changed block.
|
|
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
|
|
* Chat chip floating above its last line, at the column where it ends,
|
|
* which hands the panel the selection's 1-based lines.
|
|
* 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 { useTranslation } from "react-i18next";
|
|
import {
|
|
Compartment,
|
|
EditorSelection,
|
|
EditorState,
|
|
RangeSetBuilder,
|
|
StateEffect,
|
|
StateField,
|
|
Text,
|
|
} from "@codemirror/state";
|
|
import {
|
|
Decoration,
|
|
EditorView,
|
|
ViewPlugin,
|
|
WidgetType,
|
|
drawSelection,
|
|
gutter,
|
|
GutterMarker,
|
|
highlightActiveLine,
|
|
highlightActiveLineGutter,
|
|
keymap,
|
|
lineNumbers,
|
|
showTooltip,
|
|
type Tooltip,
|
|
type DecorationSet,
|
|
type ViewUpdate,
|
|
} from "@codemirror/view";
|
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
|
import { unifiedMergeView, type Chunk } from "@codemirror/merge";
|
|
import { chunksOf, splitDiff } from "./splitDiff";
|
|
import { highlight, tokenClass } from "../../lib/highlight";
|
|
import type { LaneBlame } from "../../lib/types";
|
|
|
|
type Blame = Extract<LaneBlame, { available: true }>;
|
|
|
|
/**
|
|
* 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 = chunksOf(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 }
|
|
);
|
|
|
|
/** A changed block by line (1-based; a count of 0 is a pure insertion or
|
|
* removal): what the panel matches against git's own change blocks. */
|
|
export type EditorBlock = { oldFrom: number; oldCount: number; newFrom: number; newCount: number };
|
|
export type BlockAction = "include" | "undo";
|
|
|
|
/** One Annotate cell: the line's commit date and author, its subject on hover. */
|
|
class BlameMarker extends GutterMarker {
|
|
constructor(
|
|
readonly text: string,
|
|
readonly title: string
|
|
) {
|
|
super();
|
|
}
|
|
override eq(other: BlameMarker) {
|
|
return other.text === this.text && other.title === this.title;
|
|
}
|
|
override toDOM() {
|
|
const el = document.createElement("span");
|
|
el.textContent = this.text;
|
|
el.title = this.title;
|
|
return el;
|
|
}
|
|
}
|
|
|
|
/** The Annotate gutter for the editable file's lines (the B side). ponytail:
|
|
* lines are matched by number, so unsaved edits above a line shift its
|
|
* annotation until the file is saved and re-annotated. */
|
|
function blameGutter(blame: Blame, notCommitted: string, onClick: (sha: string) => void) {
|
|
const shaAt = (state: EditorState, pos: number) => blame.lines[state.doc.lineAt(pos).number - 1];
|
|
return gutter({
|
|
class: "cm-ah-blame",
|
|
lineMarker(view, line) {
|
|
const sha = shaAt(view.state, line.from);
|
|
if (sha === undefined) return null;
|
|
const c = sha ? blame.commits[sha] : undefined;
|
|
return c
|
|
? new BlameMarker(`${c.date.slice(0, 10)} ${c.author}`, `${c.short} ${c.summary}`)
|
|
: new BlameMarker(notCommitted, notCommitted);
|
|
},
|
|
initialSpacer: () => new BlameMarker("0000-00-00 Author Name", ""),
|
|
domEventHandlers: {
|
|
mousedown(view, line) {
|
|
const sha = shaAt(view.state, line.from);
|
|
if (sha) onClick(sha);
|
|
return !!sha;
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
/** A chunk side's first line and line count. `to` is the start of the line
|
|
* after the chunk (or the end + 1), so its last line holds `to - 1`. */
|
|
const lineSpan = (doc: Text, from: number, to: number): [number, number] => {
|
|
const first = doc.lineAt(Math.min(from, doc.length)).number;
|
|
return [first, from >= to ? 0 : doc.lineAt(Math.min(to - 1, doc.length)).number - first + 1];
|
|
};
|
|
|
|
class BlockBar extends WidgetType {
|
|
constructor(
|
|
readonly block: EditorBlock,
|
|
readonly build: (b: EditorBlock) => HTMLElement
|
|
) {
|
|
super();
|
|
}
|
|
eq(o: BlockBar) {
|
|
const a = this.block;
|
|
const b = o.block;
|
|
return (
|
|
a.oldFrom === b.oldFrom &&
|
|
a.oldCount === b.oldCount &&
|
|
a.newFrom === b.newFrom &&
|
|
a.newCount === b.newCount
|
|
);
|
|
}
|
|
toDOM() {
|
|
return this.build(this.block);
|
|
}
|
|
}
|
|
|
|
const barsOn = StateEffect.define<boolean>();
|
|
|
|
/** One bar per chunk, under its last line (above a pure removal), rebuilt
|
|
* whenever the chunks are — and dropped while `barsOn` is false. */
|
|
function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
|
const decos = (doc: Text, chunks: readonly Chunk[]) => {
|
|
const out = new RangeSetBuilder<Decoration>();
|
|
for (const c of chunks) {
|
|
const [oldFrom, oldCount] = lineSpan(orig, c.fromA, c.toA);
|
|
const [newFrom, newCount] = lineSpan(doc, c.fromB, c.toB);
|
|
const pos = newCount ? doc.line(newFrom + newCount - 1).to : Math.min(c.fromB, doc.length);
|
|
out.add(
|
|
pos,
|
|
pos,
|
|
Decoration.widget({
|
|
block: true,
|
|
side: newCount ? 1 : -1,
|
|
widget: new BlockBar({ oldFrom, oldCount, newFrom, newCount }, build),
|
|
})
|
|
);
|
|
}
|
|
return out.finish();
|
|
};
|
|
return StateField.define<{ on: boolean; deco: DecorationSet }>({
|
|
create: () => ({ on: false, deco: Decoration.none }),
|
|
update(v, tr) {
|
|
let on = v.on;
|
|
for (const e of tr.effects) if (e.is(barsOn)) on = e.value;
|
|
const chunks = chunksOf(tr.state)?.chunks;
|
|
if (on === v.on && chunks === chunksOf(tr.startState)?.chunks) {
|
|
return tr.docChanged ? { on, deco: v.deco.map(tr.changes) } : v;
|
|
}
|
|
return { on, deco: on && chunks ? decos(tr.state.doc, chunks) : Decoration.none };
|
|
},
|
|
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
|
|
});
|
|
}
|
|
|
|
/** Add to Chat floating just above the selection's last line, at the column
|
|
* where the selection ends — so a long line never pushes it out of view.
|
|
* `build` makes its DOM from the selection's first and last 1-based line. A
|
|
* selection ending at a line's start (a whole-line drag) does not take that
|
|
* line, and the chip then sits at the start of the last line it does take. */
|
|
function chatChip(build: (start: number, end: number) => HTMLElement) {
|
|
return StateField.define<Tooltip | null>({
|
|
create: () => null,
|
|
update(tip, tr) {
|
|
if (!tr.docChanged && !tr.selection) return tip;
|
|
const { from, to, empty } = tr.state.selection.main;
|
|
if (empty) return null;
|
|
const doc = tr.state.doc;
|
|
const first = doc.lineAt(from);
|
|
const last = doc.lineAt(to);
|
|
const wholeLine = to === last.from && last.number > first.number;
|
|
const end = wholeLine ? last.number - 1 : last.number;
|
|
return {
|
|
pos: wholeLine ? doc.line(end).from : to,
|
|
above: true,
|
|
create: () => {
|
|
const dom = build(first.number, end);
|
|
dom.classList.add("cm-chat-chip");
|
|
return { dom };
|
|
},
|
|
};
|
|
},
|
|
provide: (f) => showTooltip.from(f),
|
|
});
|
|
}
|
|
|
|
const CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
|
|
const UNDO =
|
|
"border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
|
|
const UNDO_ARMED = "border-status-danger bg-status-danger/20 text-status-danger";
|
|
|
|
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))" },
|
|
// Doubled classes: drawSelection's base theme scopes its light/dark colours
|
|
// as `.ͼ1.ͼ2.cm-focused > .cm-scroller > .cm-selectionLayer …`, which a
|
|
// plain `.cm-selectionBackground` never outranks.
|
|
"&.cm-focused.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground.cm-selectionBackground, .cm-content ::selection":
|
|
{
|
|
background: "rgb(var(--accent) / 0.3)",
|
|
},
|
|
// Doubled class: outranks the base theme's light/dark-scoped tooltip frame.
|
|
".cm-tooltip.cm-chat-chip.cm-chat-chip": {
|
|
border: "1px solid rgb(var(--accent) / 0.6)",
|
|
backgroundColor: "rgb(var(--surface-1))",
|
|
},
|
|
".cm-tooltip.cm-chat-chip.cm-chat-chip:hover": { backgroundColor: "rgb(var(--surface-2))" },
|
|
[`${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" },
|
|
".cm-ah-blame .cm-gutterElement": {
|
|
color: "rgb(var(--fg-muted))",
|
|
cursor: "pointer",
|
|
fontSize: "10px",
|
|
paddingRight: "6px",
|
|
whiteSpace: "nowrap",
|
|
},
|
|
});
|
|
|
|
/** The Revert button on a changed block (unified); @codemirror/merge wires
|
|
* the click. The split layout draws its own » in the gutter. */
|
|
function revertButton(label: string) {
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.textContent = 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,
|
|
onBlock,
|
|
blockIncluded,
|
|
blame,
|
|
onBlameClick,
|
|
navRef,
|
|
onAddToChat,
|
|
}: {
|
|
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;
|
|
/** Undo one changed block in git, or flip its commit checkbox; absent = no bars. */
|
|
onBlock?: (action: BlockAction, block: EditorBlock) => void;
|
|
/** Whether a block is ticked for the commit (its checkbox's state). */
|
|
blockIncluded?: (block: EditorBlock) => boolean;
|
|
/** Annotate: each line's last commit; absent = no gutter. */
|
|
blame?: Blame;
|
|
onBlameClick?: (sha: string) => void;
|
|
/** Set to "go to the next (1) / previous (-1) changed block". */
|
|
navRef?: { current: ((dir: 1 | -1) => void) | null };
|
|
/** Add the selected lines (1-based, inclusive) to the chat; absent = no chip. */
|
|
onAddToChat?: (start: number, end: number) => void;
|
|
}) {
|
|
const { t } = useTranslation("lanes");
|
|
const host = useRef<HTMLDivElement>(null);
|
|
const view = useRef<EditorView | null>(null);
|
|
const cb = useRef({ onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t });
|
|
cb.current = { onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t };
|
|
const blameSlot = useRef(new Compartment()).current;
|
|
|
|
/** The diff view's review bar, as DOM: pinned to the visible right edge,
|
|
* zero height so it never pushes lines (or a split view's alignment). */
|
|
const buildBar = (block: EditorBlock) => {
|
|
const tr = cb.current.t;
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "flex h-0";
|
|
const pin = wrap.appendChild(document.createElement("span"));
|
|
pin.className = "sticky right-2 ml-auto h-0 w-0 self-start";
|
|
const bar = pin.appendChild(document.createElement("span"));
|
|
bar.dataset.testid = "lane-editor-block-bar";
|
|
bar.className =
|
|
"absolute right-0 top-0.5 z-10 flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card";
|
|
const chip = (text: string, title: string, cls: string) => {
|
|
const b = bar.appendChild(document.createElement("button"));
|
|
b.type = "button";
|
|
b.textContent = text;
|
|
b.title = title;
|
|
b.setAttribute("aria-label", title);
|
|
b.className = `${CHIP} ${cls}`;
|
|
return b;
|
|
};
|
|
const undo = chip(tr("changes.undoHunkShort"), tr("changes.undoHunk"), UNDO);
|
|
// The block's commit checkbox, a real one so it reads like the diff's.
|
|
const include = bar.appendChild(document.createElement("input"));
|
|
include.type = "checkbox";
|
|
include.checked = cb.current.blockIncluded?.(block) ?? true;
|
|
include.title = tr("changes.includeBlock");
|
|
include.setAttribute("aria-label", tr("changes.includeBlock"));
|
|
include.className = "h-3 w-3 cursor-pointer";
|
|
// Two clicks, like every undo git cannot give back; disarms after 3 s.
|
|
let timer = 0;
|
|
const arm = (on: boolean) => {
|
|
undo.dataset.armed = on ? "1" : "";
|
|
undo.textContent = tr(on ? "changes.undoHunkShortConfirm" : "changes.undoHunkShort");
|
|
const title = tr(on ? "changes.undoHunkConfirm" : "changes.undoHunk");
|
|
undo.title = title;
|
|
undo.setAttribute("aria-label", title);
|
|
undo.className = `${CHIP} ${on ? UNDO_ARMED : UNDO}`;
|
|
};
|
|
undo.onclick = () => {
|
|
window.clearTimeout(timer);
|
|
if (undo.dataset.armed) {
|
|
arm(false);
|
|
cb.current.onBlock?.("undo", block);
|
|
return;
|
|
}
|
|
arm(true);
|
|
timer = window.setTimeout(() => arm(false), 3000);
|
|
};
|
|
include.onchange = () => cb.current.onBlock?.("include", block);
|
|
return wrap;
|
|
};
|
|
|
|
useEffect(() => {
|
|
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
|
|
// A CRLF file stays CRLF: CodeMirror would hand `onChange` LF text, the
|
|
// panel would read that as an unsaved edit (no Undo / Keep bars) and Save
|
|
// would rewrite every line ending.
|
|
const eol = value.includes("\r\n") ? [EditorState.lineSeparator.of("\r\n")] : [];
|
|
const common = [lineNumbers(), diffColours(lang), theme, ...eol];
|
|
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.sliceDoc());
|
|
}),
|
|
blockBars(Text.of(original.split(/\r?\n/)), buildBar),
|
|
blameSlot.of([]),
|
|
...(onAddToChat
|
|
? [
|
|
chatChip((start, end) => {
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.dataset.testid = "lane-editor-add-to-chat";
|
|
b.textContent = cb.current.t("changes.addToChat");
|
|
b.className =
|
|
"rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent hover:bg-surface-2";
|
|
// Keep the editor's focus (and so its selection) through the click.
|
|
b.onmousedown = (e) => e.preventDefault();
|
|
b.onclick = () => cb.current.onAddToChat?.(start, end);
|
|
return b;
|
|
}),
|
|
]
|
|
: []),
|
|
];
|
|
let v: EditorView;
|
|
let destroy: () => void;
|
|
if (layout === "split") {
|
|
const sd = splitDiff({
|
|
parent: host.current!,
|
|
original,
|
|
value,
|
|
extA: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
|
extB: editing,
|
|
collapse: folded,
|
|
revertLabel,
|
|
});
|
|
v = sd.b;
|
|
destroy = sd.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;
|
|
if (navRef) {
|
|
// The B side's chunks, unified or split: the cursor goes to the start
|
|
// of the next / previous one and it scrolls into view.
|
|
navRef.current = (dir) => {
|
|
const ed = view.current;
|
|
const chunks = ed ? (chunksOf(ed.state)?.chunks ?? []) : [];
|
|
if (!ed || !chunks.length) return;
|
|
const at = ed.state.selection.main.head;
|
|
const next =
|
|
dir === 1
|
|
? chunks.find((c) => c.fromB > at)
|
|
: [...chunks].reverse().find((c) => c.fromB < ed.state.doc.lineAt(at).from);
|
|
if (!next) return;
|
|
ed.dispatch({
|
|
selection: EditorSelection.cursor(next.fromB),
|
|
effects: EditorView.scrollIntoView(next.fromB, { y: "center" }),
|
|
});
|
|
ed.focus();
|
|
};
|
|
}
|
|
return () => {
|
|
if (navRef) navRef.current = null;
|
|
destroy();
|
|
};
|
|
// One mount per view: the parent keys this component by file and shape.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Annotate on / off without remounting the view.
|
|
useEffect(() => {
|
|
view.current?.dispatch({
|
|
effects: blameSlot.reconfigure(
|
|
blame
|
|
? blameGutter(blame, cb.current.t("changes.notCommitted"), (sha) =>
|
|
cb.current.onBlameClick?.(sha)
|
|
)
|
|
: []
|
|
),
|
|
});
|
|
}, [blame, blameSlot]);
|
|
|
|
const bars = !!onBlock;
|
|
useEffect(() => {
|
|
view.current?.dispatch({ effects: barsOn.of(bars) });
|
|
}, [bars]);
|
|
|
|
// A value set from outside (Cancel's revert, a save's fresh text).
|
|
useEffect(() => {
|
|
const v = view.current;
|
|
if (v && v.state.sliceDoc() !== 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}
|
|
data-bars={bars}
|
|
// With the git bars up, a block's local Revert would be a second Undo.
|
|
className="[&[data-bars=true]_.cm-chunkButtons]:hidden [&[data-bars=true]_.cm-merge-revert_button]:hidden min-h-0 flex-1 overflow-hidden rounded border border-border-light"
|
|
/>
|
|
);
|
|
}
|