feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)
- 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
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* @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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
* to open it
|
||||
* review — every file's full diff, stacked, for reading a whole change-set
|
||||
* file — one file, as a diff or as its whole content (issue #35); a
|
||||
* working file is always the editor (`DiffEditor`, diffed
|
||||
* against HEAD / the branch's merge base, unified or side by
|
||||
* side): Changes folds the unchanged lines away, Whole file
|
||||
* shows them; any other file opens on its read-only diff; a
|
||||
* whole markdown file can also be read rendered (Preview), and an
|
||||
* image file is shown as the picture (`GET …/git/raw`)
|
||||
* history — recent commits; opening one shows that commit's diff
|
||||
@@ -45,7 +49,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -57,6 +61,7 @@ import {
|
||||
Code2,
|
||||
Columns2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
FoldVertical,
|
||||
Folder,
|
||||
@@ -77,12 +82,19 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useArmed } from "../../hooks/useArmed";
|
||||
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||
import {
|
||||
LaneDiffView,
|
||||
LaneFileView,
|
||||
langFor,
|
||||
type DiffLayout,
|
||||
type LaneDiffChange,
|
||||
} from "./LaneDiffView";
|
||||
import { LaneConflictView } from "./LaneConflictView";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
||||
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
|
||||
import type {
|
||||
LaneChangeFile,
|
||||
LaneChangeRange,
|
||||
@@ -136,6 +148,16 @@ function Delta({
|
||||
|
||||
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
|
||||
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
|
||||
// CodeMirror is loaded only once a file is edited.
|
||||
const DiffEditor = lazy(() => import("./DiffEditor"));
|
||||
|
||||
/** A file that opens straight in the editor: a working file with text — not a
|
||||
* commit's, an image, a binary, a deleted file or a merge conflict (which
|
||||
* opens in the conflict view). */
|
||||
function editableFile(range: LaneChangeRange, path: string, f?: LaneChangeFile) {
|
||||
if (range === "commit" || IMAGE_FILE.test(path)) return false;
|
||||
return !f || (!f.binary && !f.conflicted && !f.status.includes("D"));
|
||||
}
|
||||
|
||||
/** An image file, shown as itself; a broken one (deleted, too big) says so. */
|
||||
function ImageView({ src, path }: { src: string; path: string }) {
|
||||
@@ -198,10 +220,13 @@ function TreeNodes({
|
||||
onOpenFile,
|
||||
onMenu,
|
||||
active,
|
||||
kept,
|
||||
}: {
|
||||
path: string;
|
||||
depth: number;
|
||||
children: Record<string, LaneTreeEntry[]>;
|
||||
/** Paths on the never-stage list: drawn orange, whatever their git status. */
|
||||
kept?: Set<string>;
|
||||
expanded: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
onOpenFile: (path: string) => void;
|
||||
@@ -216,17 +241,25 @@ function TreeNodes({
|
||||
<>
|
||||
{entries.map((e) => {
|
||||
const mark = e.status ? treeMark(e.status) : null;
|
||||
const isKept = !e.dir && !!kept?.has(e.path);
|
||||
return (
|
||||
<li key={e.path}>
|
||||
<button
|
||||
type="button"
|
||||
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
||||
data-tree-kept={isKept || undefined}
|
||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
||||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||||
aria-current={active === e.path ? "true" : undefined}
|
||||
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
|
||||
mark ? mark.tone : e.changed ? "text-status-warning" : "text-fg-primary"
|
||||
isKept
|
||||
? "bg-kept/10 text-kept"
|
||||
: mark
|
||||
? mark.tone
|
||||
: e.changed
|
||||
? "text-status-warning"
|
||||
: "text-fg-primary"
|
||||
} ${active === e.path ? "!bg-surface-4" : ""}`}
|
||||
>
|
||||
{e.dir ? (
|
||||
@@ -268,6 +301,7 @@ function TreeNodes({
|
||||
onOpenFile={onOpenFile}
|
||||
onMenu={onMenu}
|
||||
active={active}
|
||||
kept={kept}
|
||||
/>
|
||||
</ul>
|
||||
)}
|
||||
@@ -322,12 +356,17 @@ function FileActions({
|
||||
armed,
|
||||
onAct,
|
||||
onArm,
|
||||
kept = false,
|
||||
onKeep,
|
||||
}: {
|
||||
f: LaneChangeFile;
|
||||
busy: boolean;
|
||||
armed: boolean;
|
||||
onAct: FileAct;
|
||||
onArm: (path: string) => void;
|
||||
/** On the never-stage list: Stage all and a folder's stage skip it. */
|
||||
kept?: boolean;
|
||||
onKeep?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
return (
|
||||
@@ -350,6 +389,14 @@ function FileActions({
|
||||
<Plus className="h-3 w-3" />
|
||||
</IconButton>
|
||||
) : null}
|
||||
{onKeep && (kept || f.unstaged || f.untracked) ? (
|
||||
<IconButton
|
||||
label={t(kept ? "changes.allowStage" : "changes.neverStage")}
|
||||
onClick={() => onKeep(f.path)}
|
||||
>
|
||||
{kept ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
|
||||
</IconButton>
|
||||
) : null}
|
||||
<IconButton
|
||||
label={armed ? t("changes.discardConfirm") : t("changes.discard")}
|
||||
disabled={busy}
|
||||
@@ -425,8 +472,12 @@ function ChangeRow({
|
||||
onMenu,
|
||||
name,
|
||||
depth = 0,
|
||||
kept = false,
|
||||
onKeep,
|
||||
}: {
|
||||
f: LaneChangeFile;
|
||||
kept?: boolean;
|
||||
onKeep?: (path: string) => void;
|
||||
/** In the folder tree: the file's own name, and how deep it sits. */
|
||||
name?: string;
|
||||
depth?: number;
|
||||
@@ -461,8 +512,10 @@ function ChangeRow({
|
||||
{f.untracked ? "A" : f.status.trim()[0] || "M"}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary"
|
||||
title={f.path}
|
||||
className={`min-w-0 flex-1 truncate font-mono text-[11px] ${
|
||||
kept ? "text-fg-muted line-through" : "text-fg-primary"
|
||||
}`}
|
||||
title={kept ? `${f.path}\n${t("changes.keptHint")}` : f.path}
|
||||
>
|
||||
{name ?? f.path}
|
||||
</span>
|
||||
@@ -475,7 +528,15 @@ function ChangeRow({
|
||||
(f.conflicted ? (
|
||||
<ConflictActions f={f} busy={busy} onResolve={onResolve} />
|
||||
) : (
|
||||
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} />
|
||||
<FileActions
|
||||
f={f}
|
||||
busy={busy}
|
||||
armed={armed}
|
||||
onAct={onAct}
|
||||
onArm={onArm}
|
||||
kept={kept}
|
||||
onKeep={onKeep}
|
||||
/>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
@@ -559,11 +620,56 @@ function ChangeTreeNodes({
|
||||
const HISTORY_PAGE = 50;
|
||||
const HISTORY_MAX = 200;
|
||||
|
||||
// The history's commit graph: one fixed-height row per commit so its lanes
|
||||
// join up row to row. Mid-tone hues that read on dark and light themes alike.
|
||||
const GRAPH_ROW = 34;
|
||||
const GRAPH_COL = 12;
|
||||
const GRAPH_COLORS = [
|
||||
"#3b82f6",
|
||||
"#22c55e",
|
||||
"#f59e0b",
|
||||
"#a855f7",
|
||||
"#ef4444",
|
||||
"#14b8a6",
|
||||
"#ec4899",
|
||||
"#84cc16",
|
||||
];
|
||||
|
||||
function GraphCell({ row, width }: { row: GraphRow; width: number }) {
|
||||
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
|
||||
const y = (f: number) => f * GRAPH_ROW;
|
||||
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
width={width * GRAPH_COL}
|
||||
height={GRAPH_ROW}
|
||||
className="shrink-0"
|
||||
data-testid="lane-changes-graph"
|
||||
>
|
||||
{row.segs.map((g, i) => {
|
||||
const ym = (y(g.y1) + y(g.y2)) / 2;
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
|
||||
fill="none"
|
||||
stroke={hue(g.color)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<circle cx={x(row.col)} cy={y(0.5)} r={3.5} fill={hue(row.color)} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneChangesPanel({
|
||||
laneId,
|
||||
session,
|
||||
onNewLane,
|
||||
full = false,
|
||||
wide = full,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
laneId: number;
|
||||
@@ -578,6 +684,8 @@ export function LaneChangesPanel({
|
||||
/** The panel has the whole width: the file tree becomes a sidebar and a
|
||||
* whole file opens straight into the editor. */
|
||||
full?: boolean;
|
||||
/** Half the width or more: diffs and the editor open side by side. */
|
||||
wide?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
// One read of the working copy, shared by the header and every write below:
|
||||
@@ -607,7 +715,10 @@ export function LaneChangesPanel({
|
||||
const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null;
|
||||
const [sha, setSha] = useState<string | null>(null);
|
||||
const [context, setContext] = useState(CONTEXT_STEPS[0]);
|
||||
const [layout, setLayout] = useState<DiffLayout>("unified");
|
||||
// Side by side wherever there is room for two columns; a resize starts over
|
||||
// from that default, a click on the toggle overrides it until then.
|
||||
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
|
||||
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
|
||||
const [whole, setWhole] = useState(false);
|
||||
// A whole markdown file shown rendered instead of as source; per file.
|
||||
const [mdPreview, setMdPreview] = useState(false);
|
||||
@@ -630,33 +741,17 @@ export function LaneChangesPanel({
|
||||
// The whole-file editor's text while it is open; null = viewing.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const editWanted = useRef(false);
|
||||
// Where the editor opens: the line clicked in the read view, and that view's
|
||||
// scroll offset, so the text does not jump when the textarea replaces it.
|
||||
const editAt = useRef<{ line: number; scrollTop: number } | null>(null);
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const editing = draft !== null;
|
||||
useEffect(() => {
|
||||
const at = editAt.current;
|
||||
const el = editorRef.current;
|
||||
if (!editing || !at || !el) return;
|
||||
editAt.current = null;
|
||||
const offset = el.value
|
||||
.split("\n")
|
||||
.slice(0, at.line)
|
||||
.reduce((n, l) => n + l.length + 1, 0);
|
||||
el.setSelectionRange(offset, offset);
|
||||
el.focus({ preventScroll: true });
|
||||
el.scrollTop = at.scrollTop;
|
||||
}, [editing]);
|
||||
const startEdit = (line: number, scrollTop: number) => {
|
||||
// The text the editor diffs against: the file at HEAD (`work`) or at the
|
||||
// branch's merge base; "" for a file that is new there.
|
||||
const [baseText, setBaseText] = useState<string | null>(null);
|
||||
const startEdit = () => {
|
||||
if (!text?.sha) return;
|
||||
editAt.current = { line, scrollTop };
|
||||
setDraft(text.lines.join("\n"));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!editWanted.current || !text) return;
|
||||
editWanted.current = false;
|
||||
if (text.sha) startEdit(0, 0);
|
||||
if (text.sha) startEdit();
|
||||
// Asked for from the diff, refused once the file arrived: say why here,
|
||||
// not with an Edit button that silently does nothing.
|
||||
else setError(t(noEditReason(text)));
|
||||
@@ -692,6 +787,32 @@ export function LaneChangesPanel({
|
||||
if (!next.delete(key)) next.add(key);
|
||||
return next;
|
||||
});
|
||||
// Files the user never wants staged (a local note, a scratch folder): Stage
|
||||
// all and a folder's stage skip them; git is never told — nothing is written
|
||||
// to the repo. A viewer preference per workspace, like the panel's size.
|
||||
const neverStageKey = `agenthub.changes.neverStage.${laneId}`;
|
||||
const [neverStage, setNeverStage] = useState<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = JSON.parse(window.localStorage.getItem(neverStageKey) || "[]");
|
||||
setNeverStage(
|
||||
new Set(Array.isArray(saved) ? saved.filter((p) => typeof p === "string") : [])
|
||||
);
|
||||
} catch {
|
||||
setNeverStage(new Set());
|
||||
}
|
||||
}, [neverStageKey]);
|
||||
const toggleNeverStage = (path: string) =>
|
||||
setNeverStage((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(path)) next.add(path);
|
||||
try {
|
||||
window.localStorage.setItem(neverStageKey, JSON.stringify([...next]));
|
||||
} catch {
|
||||
/* kept for this visit only */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Where opening a file came FROM: its mode and how far the list was scrolled.
|
||||
// Reviewing a change-set is a walk down a long list, and landing back at the
|
||||
// top of it after every file is the whole cost of opening one.
|
||||
@@ -846,10 +967,15 @@ export function LaneChangesPanel({
|
||||
};
|
||||
}, [mode, laneId, query, full]);
|
||||
|
||||
// The open file's entry in the change list, and whether it is edited in
|
||||
// place: a working file with text opens as the editor in both views.
|
||||
const openEntry =
|
||||
data?.available && openFile ? data.files.find((f) => f.path === openFile) : undefined;
|
||||
const editable = mode === "file" && !!openFile && editableFile(range, openFile, openEntry);
|
||||
// The whole-file read is a different endpoint from the diff, so it is its
|
||||
// own fetch — and only while the viewer is actually showing it.
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !whole || !openFile) return;
|
||||
if (mode !== "file" || !(whole || editable) || !openFile) return;
|
||||
void api.lanes
|
||||
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
|
||||
.then(
|
||||
@@ -868,17 +994,22 @@ export function LaneChangesPanel({
|
||||
)
|
||||
)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, [mode, whole, openFile, laneId, range, sha]);
|
||||
}, [mode, whole, editable, openFile, laneId, range, sha]);
|
||||
// The editor's original side, read beside the working file. A file that is
|
||||
// not there (new since) diffs against nothing: every line is added.
|
||||
const mergeBase = data?.available ? (data.mergeBase ?? null) : null;
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
|
||||
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
|
||||
void api.lanes.gitFile(target, openFile, rev).then(
|
||||
forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")),
|
||||
forLane(laneId, () => setBaseText(""))
|
||||
);
|
||||
}, [mode, whole, editable, openFile, laneId, range, mergeBase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (full && mode === "browse") setMode("overview");
|
||||
}, [full, mode]);
|
||||
// Going full width with a working file already open: straight to the editor.
|
||||
useEffect(() => {
|
||||
if (full && mode === "file" && range !== "commit") setWhole(true);
|
||||
// Only on the switch itself — Whole file must still toggle afterwards.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [full]);
|
||||
|
||||
// A lane switch must not leave the previous lane's file open — its path
|
||||
// almost certainly does not exist in the new one.
|
||||
@@ -939,10 +1070,14 @@ export function LaneChangesPanel({
|
||||
}
|
||||
setOpenFile(path);
|
||||
setContext(CONTEXT_STEPS[0]);
|
||||
// Full width edits by default: a working file opens as the editor, and
|
||||
// Whole file toggles back to its diff. A commit is history, so a diff.
|
||||
setWhole(full && range !== "commit");
|
||||
// A file opens on its Changes. A working file is the editor folded to its
|
||||
// changes (each block with a Revert); Whole file unfolds it. Anything else
|
||||
// — a commit, a binary, a deleted file — is the read-only diff.
|
||||
const f = data?.available ? data.files.find((x) => x.path === path) : undefined;
|
||||
editWanted.current = editableFile(range, path, f);
|
||||
setWhole(false);
|
||||
setText(null);
|
||||
setBaseText(null);
|
||||
setDraft(null);
|
||||
setRawEdit(false);
|
||||
setMode("file");
|
||||
@@ -978,7 +1113,8 @@ export function LaneChangesPanel({
|
||||
if (r.available) {
|
||||
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
|
||||
}
|
||||
setDraft(null);
|
||||
// Saved, still editing: the editor stays on the text just written.
|
||||
setDraft(r.available ? r.lines.join("\n") : null);
|
||||
refreshGit();
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -1105,6 +1241,9 @@ export function LaneChangesPanel({
|
||||
{t("changes.unstage")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(fileCtx.close, () => toggleNeverStage(menuFile.path))}>
|
||||
{t(neverStage.has(menuFile.path) ? "changes.allowStage" : "changes.neverStage")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy}
|
||||
@@ -1303,7 +1442,9 @@ export function LaneChangesPanel({
|
||||
// What "Stage all" sends: every listed file with a change the index lacks.
|
||||
// Never a conflicted one — staging it would mark it resolved.
|
||||
const toStage =
|
||||
range === "work" ? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted) : [];
|
||||
range === "work"
|
||||
? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted && !neverStage.has(f.path))
|
||||
: [];
|
||||
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
|
||||
|
||||
// The armed path left the list (committed, reverted, lane switched): disarm,
|
||||
@@ -1314,11 +1455,17 @@ export function LaneChangesPanel({
|
||||
}
|
||||
}, [data, files, confirmDiscard]);
|
||||
|
||||
// A tree or quick-open pick opens the whole file — and, with an edit open,
|
||||
// would drop it, so a dirty draft asks first.
|
||||
// A tree or quick-open pick opens a changed file on its Changes like any
|
||||
// other, and an unchanged one — no diff to show — as the whole file, in the
|
||||
// editor. With an edit open it would drop it, so a dirty draft asks first.
|
||||
const openWhole = async (p: string) => {
|
||||
if (!(await confirmDrop())) return;
|
||||
showNow(p);
|
||||
// The overview's list, or — with one file open, the list is that file —
|
||||
// the working-copy facts' (capped) list of changed paths.
|
||||
const changed = (git?.available ? git.files : []).map((f) => f.path);
|
||||
if ([...files.map((f) => f.path), ...changed].includes(p)) return;
|
||||
editWanted.current = editableFile(range, p);
|
||||
setWhole(true);
|
||||
};
|
||||
|
||||
@@ -1384,6 +1531,7 @@ export function LaneChangesPanel({
|
||||
onOpenFile={(p) => void openWhole(p)}
|
||||
onMenu={treeCtx.open}
|
||||
active={full && mode === "file" ? openFile : null}
|
||||
kept={neverStage}
|
||||
/>
|
||||
</ul>
|
||||
)}
|
||||
@@ -1531,7 +1679,7 @@ export function LaneChangesPanel({
|
||||
file to get the tight view back. */}
|
||||
<IconButton
|
||||
label={t("changes.collapseContext")}
|
||||
disabled={whole || context <= CONTEXT_STEPS[0]}
|
||||
disabled={whole || draft !== null || context <= CONTEXT_STEPS[0]}
|
||||
onClick={() =>
|
||||
setContext(
|
||||
[...CONTEXT_STEPS].reverse().find((step) => step < context) ??
|
||||
@@ -1543,7 +1691,7 @@ export function LaneChangesPanel({
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={t("changes.expandContext")}
|
||||
disabled={whole || context >= LAST_CONTEXT}
|
||||
disabled={whole || draft !== null || context >= LAST_CONTEXT}
|
||||
onClick={() =>
|
||||
setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)
|
||||
}
|
||||
@@ -1556,17 +1704,35 @@ export function LaneChangesPanel({
|
||||
)}
|
||||
|
||||
{mode === "file" && (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={whole}
|
||||
disabled={dirty}
|
||||
onClick={() => setWhole(!whole)}
|
||||
className={`rounded border border-border-light px-1.5 py-0.5 text-[11px] ${
|
||||
whole ? "bg-surface-4 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{t("changes.wholeFile")}
|
||||
</button>
|
||||
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||||
{([false, true] as const).map((w) => (
|
||||
<button
|
||||
key={String(w)}
|
||||
type="button"
|
||||
aria-pressed={whole === w}
|
||||
onClick={() => {
|
||||
if (whole === w) return;
|
||||
// Both views of a working file are the same editor: the
|
||||
// draft goes along, unsaved or not.
|
||||
if (editable && draft !== null) return setWhole(w);
|
||||
// Back to Changes drops an open edit, so unsaved text asks first.
|
||||
void confirmDrop().then((ok) => {
|
||||
if (!ok) return;
|
||||
if (w) editWanted.current = editableFile(range, openFile ?? "", current);
|
||||
else setDraft(null);
|
||||
setWhole(w);
|
||||
});
|
||||
}}
|
||||
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
|
||||
whole === w
|
||||
? "bg-surface-4 text-fg-primary"
|
||||
: "text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t(w ? "changes.wholeFile" : "changes.changesView")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1622,30 +1788,38 @@ export function LaneChangesPanel({
|
||||
}`}
|
||||
>
|
||||
{mode === "history" ? (
|
||||
<ul data-testid="lane-changes-history" className="space-y-0.5">
|
||||
{(commits ?? []).map((c) => (
|
||||
<li key={c.sha}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => viewCommit(c.sha)}
|
||||
onContextMenu={(e) => commitCtx.open(e, c)}
|
||||
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
|
||||
{c.short}
|
||||
<ul data-testid="lane-changes-history">
|
||||
{(() => {
|
||||
const list = commits ?? [];
|
||||
const graph = commitGraph(list);
|
||||
return list.map((c, i) => (
|
||||
<li key={c.sha}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => viewCommit(c.sha)}
|
||||
onContextMenu={(e) => commitCtx.open(e, c)}
|
||||
style={{ height: GRAPH_ROW }}
|
||||
className="flex w-full items-center gap-1.5 rounded px-1 text-left hover:bg-surface-2"
|
||||
>
|
||||
<GraphCell row={graph.rows[i]!} width={graph.width} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
|
||||
{c.short}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
|
||||
{c.subject}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
|
||||
<span className="truncate">{c.author}</span>
|
||||
<span>{new Date(c.date).toLocaleString()}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
|
||||
{c.subject}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
|
||||
<span className="truncate">{c.author}</span>
|
||||
<span>{new Date(c.date).toLocaleString()}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</button>
|
||||
</li>
|
||||
));
|
||||
})()}
|
||||
{commits === null && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
|
||||
)}
|
||||
@@ -1831,6 +2005,8 @@ export function LaneChangesPanel({
|
||||
onArm={setConfirmDiscard}
|
||||
onResolve={resolve}
|
||||
onMenu={fileCtx.open}
|
||||
kept={neverStage.has(f.path)}
|
||||
onKeep={toggleNeverStage}
|
||||
/>
|
||||
)}
|
||||
folderAction={
|
||||
@@ -1846,7 +2022,11 @@ export function LaneChangesPanel({
|
||||
onClick={() =>
|
||||
void act(
|
||||
g.key === "staged" ? "unstage" : "stage",
|
||||
rows.map((f) => f.path)
|
||||
rows
|
||||
.filter(
|
||||
(f) => g.key === "staged" || !neverStage.has(f.path)
|
||||
)
|
||||
.map((f) => f.path)
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -1898,7 +2078,7 @@ export function LaneChangesPanel({
|
||||
binary={current.binary}
|
||||
/>
|
||||
)}
|
||||
{whole && text && draft === null && isMarkdown(openFile) && (
|
||||
{whole && text && !dirty && isMarkdown(openFile) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-preview"
|
||||
@@ -1911,9 +2091,8 @@ export function LaneChangesPanel({
|
||||
</button>
|
||||
)}
|
||||
{/* Only the working file is editable: a commit is history. */}
|
||||
{/* The whole file enters the editor by a click on its text;
|
||||
only the diff, which has no whole text to click, keeps
|
||||
an Edit button. */}
|
||||
{/* A working file opens in the editor; the read-only diff
|
||||
(Whole file off) keeps an Edit button back into it. */}
|
||||
{range !== "commit" &&
|
||||
!openImage &&
|
||||
(draft === null && whole && text && !text.sha ? (
|
||||
@@ -1923,14 +2102,7 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{t(noEditReason(text))}
|
||||
</span>
|
||||
) : draft === null && whole ? (
|
||||
text &&
|
||||
!mdPreview && (
|
||||
<span className="shrink-0 self-center text-[10px] text-fg-muted">
|
||||
{t("changes.clickToEdit")}
|
||||
</span>
|
||||
)
|
||||
) : draft === null ? (
|
||||
) : draft === null && whole ? null : draft === null ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-edit"
|
||||
@@ -1959,8 +2131,13 @@ export function LaneChangesPanel({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void confirmDrop().then((ok) => ok && setDraft(null))}
|
||||
data-testid="lane-file-cancel"
|
||||
disabled={busy || !dirty}
|
||||
onClick={() =>
|
||||
void confirmDrop().then(
|
||||
(ok) => ok && setDraft(text ? text.lines.join("\n") : null)
|
||||
)
|
||||
}
|
||||
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
{t("changes.cancelEdit")}
|
||||
@@ -1977,23 +2154,32 @@ export function LaneChangesPanel({
|
||||
range === "commit" && sha ? sha : "work"
|
||||
)}
|
||||
/>
|
||||
) : whole && draft !== null ? (
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
data-testid="lane-file-editor"
|
||||
aria-label={t("changes.editorLabel", { path: openFile })}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
if (!busy) void saveDraft();
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
wrap="off"
|
||||
className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none"
|
||||
/>
|
||||
) : !mdPreview &&
|
||||
(whole || editable) &&
|
||||
(draft !== null || (editable && text === null)) ? (
|
||||
// The editor, once both texts are in: not the diff first.
|
||||
draft === null || (baseText === null && range !== "commit") ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>}
|
||||
>
|
||||
<DiffEditor
|
||||
key={`${openFile}:${layout}:${whole}`}
|
||||
layout={layout}
|
||||
collapse={!whole}
|
||||
revertLabel={t("changes.revertBlock")}
|
||||
value={draft}
|
||||
original={baseText ?? draft}
|
||||
lang={langFor(openFile ?? "")}
|
||||
label={t("changes.editorLabel", { path: openFile })}
|
||||
onChange={setDraft}
|
||||
onSave={() => {
|
||||
if (!busy) void saveDraft();
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
) : whole ? (
|
||||
text && mdPreview ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
||||
@@ -2005,7 +2191,6 @@ export function LaneChangesPanel({
|
||||
lines={text.lines}
|
||||
truncated={text.truncated}
|
||||
fill
|
||||
onEditAt={range !== "commit" && text.sha && !busy ? startEdit : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
|
||||
@@ -76,6 +76,7 @@ export const WINDOW_THRESHOLD = 500;
|
||||
export const ROW_PX = 16;
|
||||
const OVERSCAN = 20;
|
||||
const FIXED: CSSProperties = { height: ROW_PX };
|
||||
const MIN_ROW: CSSProperties = { minHeight: ROW_PX };
|
||||
|
||||
/**
|
||||
* The `[start, end)` item range to render for a scroll position. `scrollTop`
|
||||
@@ -165,7 +166,7 @@ function Windowed({
|
||||
}
|
||||
|
||||
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
|
||||
function langFor(filePath: string) {
|
||||
export function langFor(filePath: string) {
|
||||
const ext = filePath.split(".").pop() || "";
|
||||
return canonicalLang(ext === filePath ? "plain" : ext);
|
||||
}
|
||||
@@ -177,7 +178,7 @@ function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number
|
||||
return (
|
||||
<>
|
||||
<Code text={text.slice(0, from)} lang={lang} />
|
||||
<span className="rounded-sm bg-fg-primary/15">
|
||||
<span className="rounded-sm bg-diff-mod/40">
|
||||
<Code text={text.slice(from, to)} lang={lang} />
|
||||
</span>
|
||||
<Code text={text.slice(to)} lang={lang} />
|
||||
@@ -209,9 +210,10 @@ function emphasis(
|
||||
while (start < max && del[start] === add[start]) start += 1;
|
||||
let end = 0;
|
||||
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1;
|
||||
// Nothing in common at either edge: the whole line is the change, and
|
||||
// highlighting all of it adds noise on top of the row tint.
|
||||
if (start === 0 && end === 0) return null;
|
||||
// No letter or digit in common at either edge (blanks, a `;`, a `}` do not
|
||||
// count): the whole line is the change — a removed line and an added one,
|
||||
// not an edit — and highlighting all of it adds noise on top of the row tint.
|
||||
if (!/\w/.test(del.slice(0, start) + del.slice(del.length - end))) return null;
|
||||
return {
|
||||
del: [start, del.length - end],
|
||||
add: [start, add.length - end],
|
||||
@@ -294,7 +296,8 @@ function Gutter({ n, commentable = false }: { n: number | null; commentable?: bo
|
||||
}
|
||||
|
||||
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
|
||||
* `style` pins a windowed row's height; unwindowed rows carry none. */
|
||||
* `style` pins a windowed row's height; an unwindowed row is at least one
|
||||
* line tall, so a split view's empty filler row keeps its column in step. */
|
||||
function Row({
|
||||
tint,
|
||||
style,
|
||||
@@ -310,7 +313,7 @@ function Row({
|
||||
return (
|
||||
<div
|
||||
className={`flex w-max min-w-full ${tint}`}
|
||||
style={style}
|
||||
style={style ?? MIN_ROW}
|
||||
data-diff-item={style ? "" : undefined}
|
||||
data-change-index={anchor}
|
||||
>
|
||||
@@ -320,15 +323,19 @@ function Row({
|
||||
}
|
||||
|
||||
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
|
||||
* separate from context at a glance on every theme (the /10 fill alone did not). */
|
||||
function rowTint(type: LaneDiffLine["type"] | null) {
|
||||
* separate from context at a glance on every theme (the /10 fill alone did not).
|
||||
* Green/red mark a line added or removed whole; blue (`mod`) a line edited in
|
||||
* place — a -/+ pair that keeps part of its text, its edit emphasised. */
|
||||
function rowTint(type: LaneDiffLine["type"] | null, mod = false) {
|
||||
if (mod && type !== "ctx") return "bg-diff-mod/20 shadow-[inset_3px_0_0_rgb(var(--diff-mod))]";
|
||||
if (type === "add")
|
||||
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]";
|
||||
if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
|
||||
return "";
|
||||
}
|
||||
|
||||
function markerClass(type: LaneDiffLine["type"]) {
|
||||
function markerClass(type: LaneDiffLine["type"], mod = false) {
|
||||
if (mod && type !== "ctx") return "font-bold text-diff-mod";
|
||||
if (type === "add") return "font-bold text-status-success";
|
||||
if (type === "del") return "font-bold text-status-danger";
|
||||
return "text-fg-muted";
|
||||
@@ -485,11 +492,11 @@ function UnifiedLine({
|
||||
}) {
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
|
||||
<Row tint={rowTint(line.type, !!emph)} style={style} anchor={anchor}>
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} commentable />
|
||||
<span className="whitespace-pre px-2">
|
||||
<span className={`select-none ${markerClass(line.type)}`}>
|
||||
<span className={`select-none ${markerClass(line.type, !!emph)}`}>
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
@@ -558,7 +565,7 @@ function SplitLine({
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
|
||||
<Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
|
||||
<Gutter
|
||||
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||||
commentable={side === "right"}
|
||||
@@ -941,16 +948,12 @@ export function LaneFileView({
|
||||
lines,
|
||||
truncated,
|
||||
fill = false,
|
||||
onEditAt,
|
||||
}: {
|
||||
path: string;
|
||||
lines: string[];
|
||||
truncated?: boolean;
|
||||
/** Take the parent's height instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
/** Set when the file is editable: a click on the text (0-based line, and
|
||||
* where the view was scrolled) asks to open the editor there. */
|
||||
onEditAt?: (line: number, scrollTop: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(path);
|
||||
@@ -959,12 +962,9 @@ export function LaneFileView({
|
||||
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
|
||||
const row = (text: string, i: number, style?: CSSProperties) => (
|
||||
<Row key={i} tint="" style={style}>
|
||||
{/* `contents`: a line marker for the click handler, not a layout box. */}
|
||||
<span data-ln={i} className="contents">
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
</Row>
|
||||
);
|
||||
@@ -972,27 +972,7 @@ export function LaneFileView({
|
||||
<div
|
||||
data-testid="lane-file-view"
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
tabIndex={onEditAt ? 0 : undefined}
|
||||
onClick={
|
||||
onEditAt &&
|
||||
((e) => {
|
||||
// A click that ends a drag-selection is copying, not asking to edit.
|
||||
if (window.getSelection()?.toString()) return;
|
||||
const ln = (e.target as HTMLElement).closest("[data-ln]")?.getAttribute("data-ln");
|
||||
onEditAt(Number(ln ?? 0), e.currentTarget.scrollTop);
|
||||
})
|
||||
}
|
||||
onKeyDown={
|
||||
onEditAt &&
|
||||
((e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
onEditAt(0, e.currentTarget.scrollTop);
|
||||
})
|
||||
}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}${
|
||||
onEditAt ? " cursor-text" : ""
|
||||
}`}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
|
||||
>
|
||||
{win.on ? (
|
||||
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* @file The top of the Changes panel: the lane's working-copy line (branch,
|
||||
* head, subject, and the uncommitted counts) and the git action bar under it
|
||||
* (`LaneGitBar`); plus the same counts drawn vertical on the CLOSED Changes
|
||||
* rail (`LaneGitRailCounts`), where hovering them opens the per-file panel —
|
||||
* the open panel lists those files itself, so its header chip opens nothing.
|
||||
* head, subject) and the git action bar under it (`LaneGitBar`); plus the
|
||||
* uncommitted counts drawn vertical on the CLOSED Changes rail
|
||||
* (`LaneGitRailCounts`), where hovering them opens the per-file panel — headed
|
||||
* by the real total, listing the server's first 20, its "… more files" opening
|
||||
* the Changes panel — the open panel lists those files itself, so its header
|
||||
* shows no counts.
|
||||
*
|
||||
* It used to sit in the lane card, a column away from the diff it describes —
|
||||
* a pull there changed files the Changes panel only noticed on its next poll.
|
||||
@@ -102,6 +104,7 @@ const GIT_STATUS_STYLE: Record<string, string> = {
|
||||
function LaneGitFilesPanel({
|
||||
files,
|
||||
hidden,
|
||||
onShowAll,
|
||||
anchor,
|
||||
side = "below",
|
||||
onPointerEnter,
|
||||
@@ -109,6 +112,8 @@ function LaneGitFilesPanel({
|
||||
}: {
|
||||
files: LaneGitFile[];
|
||||
hidden: number;
|
||||
/** Opens the Changes panel, which lists every file the cap left out. */
|
||||
onShowAll?: () => void;
|
||||
/** The counts button this panel hangs off. */
|
||||
anchor: HTMLElement | null;
|
||||
/** `left` beside a chip on the right-edge rail, where below would cover it. */
|
||||
@@ -146,13 +151,14 @@ function LaneGitFilesPanel({
|
||||
// partial one. `hidden` says how many are missing.
|
||||
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
|
||||
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
|
||||
const total = files.length + hidden;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="lane-git-files"
|
||||
role="group"
|
||||
aria-label={t("git.filesHeading", { count: files.length })}
|
||||
aria-label={t("git.filesHeading", { count: total })}
|
||||
// The pointer has to be able to travel INTO the panel to scroll it, so
|
||||
// the panel answers the same enter/leave pair the trigger does.
|
||||
onMouseEnter={onPointerEnter}
|
||||
@@ -169,7 +175,7 @@ function LaneGitFilesPanel({
|
||||
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
|
||||
>
|
||||
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: total })}</span>
|
||||
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
@@ -206,30 +212,40 @@ function LaneGitFilesPanel({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hidden > 0 && (
|
||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</p>
|
||||
)}
|
||||
{hidden > 0 &&
|
||||
(onShowAll ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-git-files-more"
|
||||
onClick={onShowAll}
|
||||
className="mt-1.5 w-full border-t border-border pt-1.5 text-left text-[10px] text-accent hover:underline"
|
||||
>
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</button>
|
||||
) : (
|
||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</p>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The uncommitted counts as a chip. With `details` it opens the per-file panel
|
||||
* on hover, focus or click — the closed Changes rail, `vertical`, where nothing
|
||||
* else lists the files. In the open panel the list below already does, so the
|
||||
* header shows the chip alone.
|
||||
* The uncommitted counts as a chip that opens the per-file panel on hover,
|
||||
* focus or click — the closed Changes rail, `vertical`, where nothing else
|
||||
* lists the files.
|
||||
*/
|
||||
export function LaneGitCounts({
|
||||
git,
|
||||
vertical = false,
|
||||
details = true,
|
||||
onShowAll,
|
||||
}: {
|
||||
git: Extract<LaneGitFacts, { available: true }>;
|
||||
vertical?: boolean;
|
||||
details?: boolean;
|
||||
/** See `LaneGitFilesPanel`. */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
@@ -253,13 +269,6 @@ export function LaneGitCounts({
|
||||
const chip = `shrink-0 rounded-full border border-status-warning/40 bg-status-warning/10 text-[10.5px] text-status-warning ${
|
||||
vertical ? "px-px py-2 [writing-mode:vertical-rl]" : "px-2 py-px"
|
||||
}`;
|
||||
if (!details) {
|
||||
return (
|
||||
<span data-testid="lane-git-counts" className={chip}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -288,6 +297,13 @@ export function LaneGitCounts({
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
onShowAll={
|
||||
onShowAll &&
|
||||
(() => {
|
||||
setFilesOpen(false);
|
||||
onShowAll();
|
||||
})
|
||||
}
|
||||
anchor={anchor}
|
||||
side={vertical ? "left" : "below"}
|
||||
onPointerEnter={openFiles}
|
||||
@@ -300,9 +316,18 @@ export function LaneGitCounts({
|
||||
|
||||
/** The counts on the closed Changes rail: its own facts read, polled only
|
||||
* while the rail is shown (the open panel owns the read otherwise). */
|
||||
export function LaneGitRailCounts({ laneId, session }: { laneId: number; session?: string }) {
|
||||
export function LaneGitRailCounts({
|
||||
laneId,
|
||||
session,
|
||||
onShowAll,
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
/** Opens the Changes panel from the file list's "… more files". */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
const [git] = useLaneGitFacts(laneId, session);
|
||||
return git?.available ? <LaneGitCounts git={git} vertical /> : null;
|
||||
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
|
||||
}
|
||||
|
||||
export function LaneGitHeader({
|
||||
@@ -339,7 +364,6 @@ export function LaneGitHeader({
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||
</div>
|
||||
<LaneGitCounts git={git} details={false} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
|
||||
@@ -160,7 +160,7 @@ export function WorkspaceRow({
|
||||
>
|
||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
|
||||
<span className="truncate text-[13px] text-fg-primary">{name}</span>
|
||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||
</button>
|
||||
)}
|
||||
{!renaming && (
|
||||
|
||||
@@ -437,7 +437,7 @@ export function WorkspaceSessionList({
|
||||
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||
<p className="py-1 pl-[42px] text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||
{failure}
|
||||
{confirmArchive}
|
||||
</>
|
||||
@@ -445,7 +445,11 @@ export function WorkspaceSessionList({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
||||
<ul
|
||||
data-testid={`workspace-sessions-${lane.id}`}
|
||||
// A guide line under the workspace's chevron; the rows start past it.
|
||||
className="relative space-y-px pl-5 before:pointer-events-none before:absolute before:inset-y-0 before:left-[13px] before:w-px before:bg-border before:content-['']"
|
||||
>
|
||||
{fresh.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
@@ -460,7 +464,7 @@ export function WorkspaceSessionList({
|
||||
}
|
||||
onContextMenu={(e) => ctx.open(e, r)}
|
||||
title={`${r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}\n${t("tree.splitHint")}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
|
||||
r.id === selectedRunId
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
@@ -472,7 +476,7 @@ export function WorkspaceSessionList({
|
||||
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
|
||||
{r.startedAt ? timeAgo(r.startedAt) : null}
|
||||
{r.startedAt ? timeAgo(r.startedAt, true) : null}
|
||||
</span>
|
||||
{r.tool === "shell" ? (
|
||||
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
|
||||
@@ -490,7 +494,7 @@ export function WorkspaceSessionList({
|
||||
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
|
||||
if (renaming === s.id) {
|
||||
return (
|
||||
<li key={s.id} className="py-0.5 pl-7 pr-2">
|
||||
<li key={s.id} className="py-0.5 pr-2">
|
||||
<input
|
||||
data-testid={`workspace-session-rename-${s.id}`}
|
||||
aria-label={t("tree.renameSession")}
|
||||
@@ -544,7 +548,7 @@ export function WorkspaceSessionList({
|
||||
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
||||
: ""
|
||||
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
|
||||
selected
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
@@ -564,12 +568,12 @@ export function WorkspaceSessionList({
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
|
||||
{timeAgo(s.last_activity ?? s.started_at)}
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||
{timeAgo(s.last_activity ?? s.started_at, true)}
|
||||
</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
|
||||
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
|
||||
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
@@ -595,7 +599,7 @@ export function WorkspaceSessionList({
|
||||
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
|
||||
onContextMenu={(e) => closedCtx.open(e, r)}
|
||||
title={t("tree.closedTerminal")}
|
||||
className="flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
className="flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
@@ -604,7 +608,7 @@ export function WorkspaceSessionList({
|
||||
<span className="sr-only">{t("tree.state.idle")}</span>
|
||||
<span className="truncate">{t("tabs.terminal")}</span>
|
||||
<span className="ml-auto shrink-0 text-[11px]">
|
||||
{timeAgo(r.ended_at ?? r.started_at)}
|
||||
{timeAgo(r.ended_at ?? r.started_at, true)}
|
||||
</span>
|
||||
<SquareTerminal
|
||||
aria-hidden
|
||||
@@ -629,7 +633,7 @@ export function WorkspaceSessionList({
|
||||
type="button"
|
||||
data-testid={`workspace-sessions-more-${lane.id}`}
|
||||
onClick={() => own && setLimit(own.total)}
|
||||
className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("tree.seeAll", { count: more })}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
|
||||
* count as edited in place (blue) rather than added or removed whole, and the
|
||||
* editor's own diff classes on a real mount.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import DiffEditor, { keepsText } from "../DiffEditor";
|
||||
|
||||
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
|
||||
const EMPTY = Object.assign([], { item: () => null }) as unknown as DOMRectList;
|
||||
Range.prototype.getClientRects ??= () => EMPTY;
|
||||
Range.prototype.getBoundingClientRect ??= () => new DOMRect();
|
||||
|
||||
describe("keepsText", () => {
|
||||
it("is false for a line whose every non-blank character changed", () => {
|
||||
expect(keepsText(" added()", 10, [[12, 19]])).toBe(false);
|
||||
expect(keepsText("", 0, [])).toBe(false);
|
||||
// Only a `;` survived: not the same line.
|
||||
expect(keepsText("export x;", 0, [[0, 8]])).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when some text outside the changes survives", () => {
|
||||
expect(keepsText("const x = 2;", 0, [[10, 11]])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiffEditor", () => {
|
||||
it("marks a whole new line green and a line edited in place blue", async () => {
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
value={"keep\nconst x = 2;\nbrand new"}
|
||||
original={"keep\nconst x = 1;"}
|
||||
lang="ts"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-changedLine")).toHaveLength(2));
|
||||
const lines = [...container.querySelectorAll<HTMLElement>(".cm-changedLine")];
|
||||
expect(lines.map((l) => l.classList.contains("cm-ah-mod"))).toEqual([true, false]);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector(".cm-deletedLine")).toHaveAttribute("data-ah", "mod")
|
||||
);
|
||||
});
|
||||
|
||||
it("side by side: the original read-only on the left, its edited line blue", async () => {
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
layout="split"
|
||||
value={"keep\nconst x = 2;"}
|
||||
original={"keep\nconst x = 1;"}
|
||||
lang="ts"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(2));
|
||||
const [left, right] = [...container.querySelectorAll<HTMLElement>(".cm-content")];
|
||||
expect(left).toHaveAttribute("contenteditable", "false");
|
||||
expect(right).toHaveAttribute("contenteditable", "true");
|
||||
expect(container.querySelector(".cm-merge-a .cm-changedLine.cm-ah-mod")).not.toBeNull();
|
||||
expect(container.querySelector(".cm-merge-b .cm-changedLine.cm-ah-mod")).not.toBeNull();
|
||||
expect(container.querySelector(".cm-merge-revert button")).toHaveAttribute("title", "Revert");
|
||||
});
|
||||
|
||||
it("folds unchanged lines away when collapsed, and offers Revert on a block", async () => {
|
||||
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
collapse
|
||||
value={`${same}\nchanged`}
|
||||
original={`${same}\noriginal`}
|
||||
lang="plain"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelector(".cm-collapsedLines")).not.toBeNull());
|
||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>(".cm-chunkButtons button")];
|
||||
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,36 @@ import { api } from "../../../lib/api";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
// The themed confirm needs its host (App); answer it here.
|
||||
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
|
||||
vi.mock("../DiffEditor", () => ({
|
||||
default: (p: {
|
||||
value: string;
|
||||
original: string;
|
||||
layout?: string;
|
||||
collapse?: boolean;
|
||||
label: string;
|
||||
onChange: (v: string) => void;
|
||||
onSave: () => void;
|
||||
}) => (
|
||||
<textarea
|
||||
data-testid="lane-file-editor"
|
||||
data-original={p.original}
|
||||
data-layout={p.layout}
|
||||
data-collapse={String(!!p.collapse)}
|
||||
aria-label={p.label}
|
||||
autoFocus
|
||||
value={p.value}
|
||||
onChange={(e) => p.onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
p.onSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({
|
||||
confirmDiscard: vi.fn(async () => true),
|
||||
confirmDialog: vi.fn(async () => true),
|
||||
@@ -90,6 +120,11 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
|
||||
};
|
||||
}
|
||||
|
||||
/** A changed file opens in the editor; Whole file toggles to its read-only diff. */
|
||||
async function toDiff(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
|
||||
}
|
||||
|
||||
/** The overview row's discard button for one path. */
|
||||
function discardOf(path: string) {
|
||||
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
|
||||
@@ -98,6 +133,7 @@ function discardOf(path: string) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem("agenthub.changes.neverStage.7");
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
@@ -113,6 +149,7 @@ beforeEach(() => {
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
refs: ["HEAD -> feat/thing"],
|
||||
parents: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -192,6 +229,7 @@ describe("LaneChangesPanel", () => {
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
await toDiff(user);
|
||||
// The highlighter splits a line into one span per token, so assert on the
|
||||
// rendered text of the whole view rather than on a single node.
|
||||
const view = await screen.findByTestId("lane-diff-view");
|
||||
@@ -247,6 +285,45 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a never-stage file out of Stage all, without telling git", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
|
||||
await user.click(keep!); // the first row: server/lib/git-ops.js
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
|
||||
"server/lib/git-ops.js",
|
||||
]);
|
||||
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
|
||||
// And back: the row's button now offers the way out.
|
||||
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
|
||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
|
||||
});
|
||||
|
||||
it("draws a never-stage file orange in the Files tree", async () => {
|
||||
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [
|
||||
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
|
||||
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<Tabbed laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
||||
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
|
||||
expect(await row("notes.txt")).toHaveClass("text-kept");
|
||||
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
|
||||
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
||||
});
|
||||
|
||||
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -393,12 +470,12 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("README.md"));
|
||||
await screen.findByTestId("lane-file-view");
|
||||
await screen.findByTestId("lane-file-editor");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
|
||||
});
|
||||
|
||||
it("shows an image file as the picture, with no edit control", async () => {
|
||||
@@ -427,7 +504,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
|
||||
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -452,29 +529,29 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
|
||||
await user.click(await within(sidebar).findByText("main.ts"));
|
||||
|
||||
// Opening a file only shows it: no editor until the text itself is clicked.
|
||||
const view = await screen.findByTestId("lane-file-view");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
await user.click(view);
|
||||
// Opening a file is editing it, diffed against its HEAD text.
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
expect(editor).toHaveFocus();
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "HEAD");
|
||||
expect(editor).toHaveAttribute("data-original", "const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
expect(screen.getByTestId("lane-file-cancel")).toBeDisabled();
|
||||
await user.type(editor, "x");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
// Cancel asks before dropping an edit: "keep" keeps it…
|
||||
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await user.click(screen.getByTestId("lane-file-cancel"));
|
||||
expect(confirmDiscard).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
|
||||
// …and "discard" drops it and goes back to reading.
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
// …and "discard" reverts it, still editing.
|
||||
await user.click(screen.getByTestId("lane-file-cancel"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("at full width opens a changed file from the overview for reading, not editing", async () => {
|
||||
it("edits a changed file on its Changes, side by side when wide, and keeps the edit on Whole file", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
@@ -488,31 +565,40 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await screen.findByRole("button", { name: "Changes", pressed: true });
|
||||
// Changes is the editor folded to its changes, side by side at full width.
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("a");
|
||||
expect(editor).toHaveAttribute("data-collapse", "true");
|
||||
expect(editor).toHaveAttribute("data-layout", "split");
|
||||
await user.type(editor, "b");
|
||||
// Whole file unfolds the same edit — no prompt, nothing dropped.
|
||||
vi.mocked(confirmDiscard).mockClear();
|
||||
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
|
||||
const whole = await screen.findByTestId("lane-file-editor");
|
||||
expect(whole).toHaveAttribute("data-collapse", "false");
|
||||
expect(whole).toHaveValue("ab");
|
||||
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("ab");
|
||||
expect(confirmDiscard).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
});
|
||||
|
||||
it("does not enter the editor when a click only ends a text selection", async () => {
|
||||
it("opens diffs unified at the narrow width", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["copy me"],
|
||||
lines: ["a"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||
const view = await screen.findByTestId("lane-file-view");
|
||||
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
|
||||
toString: () => "copy",
|
||||
} as unknown as Selection);
|
||||
await user.click(view);
|
||||
spy.mockRestore();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveAttribute("data-layout", "unified");
|
||||
});
|
||||
|
||||
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
||||
@@ -640,6 +726,7 @@ describe("LaneChangesPanel", () => {
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
|
||||
await user.click(await screen.findByText("feat: the commit"));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -785,7 +872,6 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
await user.clear(editor);
|
||||
@@ -798,9 +884,9 @@ describe("LaneChangesPanel", () => {
|
||||
"const whole = 2;",
|
||||
"b".repeat(40)
|
||||
);
|
||||
// Back to reading, showing what was saved.
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 2;");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
// Still editing, on what was saved: nothing left to save.
|
||||
await waitFor(() => expect(screen.getByTestId("lane-file-save")).toBeDisabled());
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 2;");
|
||||
});
|
||||
|
||||
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
|
||||
@@ -844,7 +930,6 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
await user.type(await screen.findByTestId("lane-file-editor"), "y");
|
||||
await user.click(screen.getByTestId("lane-file-save"));
|
||||
|
||||
@@ -885,6 +970,7 @@ describe("LaneChangesPanel", () => {
|
||||
})
|
||||
);
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await toDiff(user);
|
||||
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
|
||||
@@ -896,6 +982,7 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await toDiff(user);
|
||||
await screen.findByRole("button", { name: "Show more surrounding lines" });
|
||||
|
||||
// Collapse is dead at the tightest step, and live once expanded — an
|
||||
@@ -926,7 +1013,7 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await screen.findByText(/server\/lib\/git-ops\.js/);
|
||||
await toDiff(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -59,6 +59,22 @@ describe("LaneDiffView — side by side", () => {
|
||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||
});
|
||||
|
||||
it("keeps an empty filler row one line tall, so the columns stay in step", () => {
|
||||
// Two lines removed, none added: the right column's two rows are fillers.
|
||||
const file = fileWith([
|
||||
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
|
||||
]);
|
||||
render(<LaneDiffView file={file} layout="split" />);
|
||||
const fillers = [
|
||||
...screen
|
||||
.getByTestId("lane-diff-split-right")
|
||||
.querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||||
];
|
||||
expect(fillers).toHaveLength(2);
|
||||
for (const row of fillers) expect(row.style.minHeight).toBe("16px");
|
||||
});
|
||||
|
||||
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
||||
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
|
||||
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
|
||||
@@ -180,24 +196,40 @@ function rowOf(container: HTMLElement, needle: string): HTMLElement {
|
||||
}
|
||||
|
||||
describe("LaneDiffView — row styling", () => {
|
||||
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const add = rowOf(container, "x = 2");
|
||||
const del = rowOf(container, "x = 1");
|
||||
// A line added or removed whole: nothing in common with its pair.
|
||||
const WHOLE = fileWith([
|
||||
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "abc" },
|
||||
{ type: "add", oldLine: null, newLine: 2, text: "xyz" },
|
||||
]);
|
||||
|
||||
it("tints whole-line add/del rows green/red with a left bar; context stays plain", () => {
|
||||
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
|
||||
const add = rowOf(container, "xyz");
|
||||
const del = rowOf(container, "abc");
|
||||
const ctx = rowOf(container, "keep()");
|
||||
expect(add.className).toContain("bg-status-success/20");
|
||||
expect(add.className).toContain("--status-success");
|
||||
expect(del.className).toContain("bg-status-danger/20");
|
||||
expect(del.className).toContain("--status-danger");
|
||||
expect(ctx.className).not.toMatch(/status-(success|danger)/);
|
||||
expect(ctx.className).not.toMatch(/status-(success|danger)|diff-mod/);
|
||||
});
|
||||
|
||||
it("tints a line edited in place blue on both sides", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
for (const text of ["x = 1", "x = 2"]) {
|
||||
const row = rowOf(container, text);
|
||||
expect(row.className).toContain("bg-diff-mod/20");
|
||||
expect(row.className).not.toMatch(/status-(success|danger)/);
|
||||
}
|
||||
});
|
||||
|
||||
it("colours the +/- marker bold in its status colour, context marker muted", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
|
||||
const marker = (row: HTMLElement) =>
|
||||
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
|
||||
const plus = marker(rowOf(container, "x = 2"));
|
||||
const minus = marker(rowOf(container, "x = 1"));
|
||||
const plus = marker(rowOf(container, "xyz"));
|
||||
const minus = marker(rowOf(container, "abc"));
|
||||
const ctx = marker(rowOf(container, "keep()"));
|
||||
expect(plus.textContent).toBe("+");
|
||||
expect(plus.className).toContain("font-bold");
|
||||
@@ -210,7 +242,7 @@ describe("LaneDiffView — row styling", () => {
|
||||
|
||||
it("emphasises only the changed span of a paired del/add", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map(
|
||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-diff-mod\\/40")].map(
|
||||
(e) => e.textContent
|
||||
);
|
||||
expect(emph).toEqual(["1", "2"]);
|
||||
@@ -226,7 +258,7 @@ describe("LaneDiffView — row styling", () => {
|
||||
layout="unified"
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
|
||||
expect(container.querySelector("span.bg-diff-mod\\/40")).toBeNull();
|
||||
});
|
||||
|
||||
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("LaneGitHeader", () => {
|
||||
behind: null,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
it("renders the live branch, head and subject", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
@@ -57,7 +57,6 @@ describe("LaneGitHeader", () => {
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
|
||||
});
|
||||
|
||||
it("drops the previous lane's facts the moment the lane changes", async () => {
|
||||
@@ -119,16 +118,26 @@ describe("LaneGitHeader", () => {
|
||||
render(<LaneGitRailCounts laneId={1} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("40 files changed");
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("shows the counts in the open panel's header without the hover list", async () => {
|
||||
it("opens the Changes panel from the file list's “more files”", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
const onShowAll = vi.fn();
|
||||
render(<LaneGitRailCounts laneId={1} onShowAll={onShowAll} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
fireEvent.click(screen.getByTestId("lane-git-files-more"));
|
||||
expect(onShowAll).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no counts in the open panel's header: the list below already does", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.focus(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
await screen.findByTestId("lane-git");
|
||||
expect(screen.queryByTestId("lane-git-counts")).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the rail's counts vertical", async () => {
|
||||
|
||||
Reference in New Issue
Block a user