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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user