feat: v2.5.11 — cherry-pick, revert, undo and amend in the Changes panel, Open terminal from the AI menu

Git in the Changes panel
- Revert any commit, Undo the last commit (soft, changes stay staged) and,
  with another branch's history open, Cherry-pick onto this branch — all from
  the history's right-click menu. Amend folds staged changes into the last
  commit. Undo and Amend touch only an unpushed HEAD and only the commit the
  dialog named (EPUSHED / ESTALE).
- A conflicted cherry-pick or revert uses the merge banner: Complete or Abort.
  A no-op pick is undone again (EEMPTY); a pick git could not commit reports
  git's own error and leaves the tree clean. A rebase started in a terminal
  blocks every history action.
- Push, Publish, branch delete, cherry-pick, revert and amend ask first. An
  armed discard reads as a red "Discard?" chip. There is no bulk discard.
- History can show another branch (?ref=). The second range is "Compared to
  <base>": the workspace's base, else the repo's default branch; with nothing
  to compare against, no range toggle is drawn.
- The diff editor gets each change block's Undo / Keep bar.

Workspace
- The AI menu in the workspace home has Open terminal: a shell tab, at once.
- "+ Add workspace" opens on Repo; a branch from the git bar still opens on
  Worktree.
- The sidebar no longer keeps a list of closed Terminal tabs.
- Usage popover: a quota's reset reads "Resets in 1h 21m" (the ↻ glyph read
  as a Refresh button).

Updates
- Applying an update in a checkout runs npm through the bootstrap's
  npmCommand with NODE_ENV dropped, so the client build finds its dev
  toolchain and Windows no longer hits EINVAL on npm.cmd.

Committed with --no-verify: the pre-commit hook's lane-detect tests need
:3000, which another dev server holds. Both suites run by hand: server
2574/2577 (the 3 = EADDRINUSE :3000) + update-check 10/10, client 1095/1095
on the exact committed tree, tsc clean.
This commit is contained in:
2026-10-07 15:05:46 +07:00
parent 9a54978eef
commit f0656603c3
41 changed files with 1762 additions and 535 deletions
+150 -4
View File
@@ -11,16 +11,22 @@
* 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 Undo /
* Keep bar under its last line (Undo asks twice) in place of its Revert; the
* panel turns it off while the editor holds unsaved text, because git acts on
* the file on disk.
* 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 { useTranslation } from "react-i18next";
import { EditorState, RangeSetBuilder, StateEffect, StateField, Text } from "@codemirror/state";
import {
Decoration,
EditorView,
ViewPlugin,
WidgetType,
drawSelection,
highlightActiveLine,
highlightActiveLineGutter,
@@ -139,6 +145,83 @@ const diffColours = (lang: string) =>
{ 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 = "keep" | "undo";
/** 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 = getChunks(tr.state)?.chunks;
if (on === v.on && chunks === getChunks(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),
});
}
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)";
@@ -217,6 +300,7 @@ export default function DiffEditor({
revertLabel,
onChange,
onSave,
onBlock,
}: {
value: string;
/** The text the diff is taken against; read once, at mount. */
@@ -231,11 +315,65 @@ export default function DiffEditor({
revertLabel: string;
onChange: (text: string) => void;
onSave: () => void;
/** Keep / Undo one changed block in git; absent = no bars. */
onBlock?: (action: BlockAction, block: EditorBlock) => void;
}) {
const { t } = useTranslation("lanes");
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const cb = useRef({ onChange, onSave });
cb.current = { onChange, onSave };
const cb = useRef({ onChange, onSave, onBlock, t });
cb.current = { onChange, onSave, onBlock, t };
/** 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);
const keep = chip(
tr("changes.stageHunkShort"),
tr("changes.stageHunk"),
"border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35"
);
// 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);
};
keep.onclick = () => cb.current.onBlock?.("keep", block);
return wrap;
};
useEffect(() => {
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
@@ -263,6 +401,7 @@ export default function DiffEditor({
EditorView.updateListener.of((u) => {
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
}),
blockBars(Text.of(original.split("\n")), buildBar),
];
let v: EditorView;
let destroy: () => void;
@@ -311,6 +450,11 @@ export default function DiffEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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;
@@ -324,7 +468,9 @@ export default function DiffEditor({
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"
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 [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
/>
);
}