e94038481e
- File Explorer (Files in the sidebar, /files; also from a workspace's Files tab, portalled so no panel clips it), laid out like GNOME Files: places (Home, workspaces, Trash), Back/Forward/Up, path bar, search, icon grid with image thumbnails or list, labelled New/Upload, selection action bar, hidden files behind Ctrl+H, right-click menus and the usual keys. Make, rename, move, copy (within a workspace, between workspaces, home ↔ workspace), delete into a per-account trash restorable for 7 days, streamed upload of files and whole folders (≤ 500 MB a batch, Replace / Keep both / Skip on a taken name), download a file or a zip of anything. server/lib/file-explorer.js, /api/lanes/:id/fs/*, /api/homes/:who/fs/*. - The user's home: every dot entry at its top stays hidden and refused (also via a symlink, download, zip or as a copy source); an admin may view other users' homes read-only, audited. - write-as learns move/copy (re-run `sudo agenthub server install-system`). - Hardening: a write to a workspace re-checks its owner by the live role; S3 keeps its server.conf if the file stops reading and a tmux call never falls back to the host's server; the AI Proxy gateway marks what it relays and the dashboard refuses it; a NUL in a pane's argv/env is refused. - OpenAPI for the new routes (server/openapi-extra/files.js); docs (API, SERVER-MODE, CLAUDE.md) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3146 lines
126 KiB
TypeScript
3146 lines
126 KiB
TypeScript
/**
|
||
* @file The Changes panel: the lane's code beside its console, in the five
|
||
* shapes a review actually needs —
|
||
*
|
||
* overview — every changed file, in a folder tree (JetBrains' commit view):
|
||
* Changes and Unversioned Files, each file, folder and group
|
||
* with a checkbox, its +/- counts and its discard action; click
|
||
* one 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, each block with the diff's Undo / Keep while nothing
|
||
* is unsaved): 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
|
||
* browse — quick-open by path, or the repo's tracked file tree (the
|
||
* mode switcher's Files button; at full width the sidebar);
|
||
* a row dragged onto a prompt box attaches it as `@path`
|
||
*
|
||
* and three ranges: `work` (uncommitted, what the agent has touched right
|
||
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
|
||
* `commit` (one commit, reached from history).
|
||
*
|
||
* A file can also be opened from outside the panel (`openRequest`: a chat's
|
||
* "Edited N files" card), the same way a click in the tree opens it.
|
||
*
|
||
* Only the overview polls. Review, file, history and browse are a deliberate
|
||
* read of a frozen moment — re-fetching under the reader's scroll position is
|
||
* worse than a slightly stale view, and those are the expensive shapes.
|
||
*
|
||
* Rendering lives in LaneDiffView.tsx. Every write goes through
|
||
* `POST /api/lanes/:id/git/:action`, and `discard` is asked for twice here
|
||
* before the server is told `confirm: true` — it is the one action in this
|
||
* panel git cannot undo.
|
||
*
|
||
* During a merge, cherry-pick or revert (issue #45) a banner offers
|
||
* **Complete** (only once no file is conflicted) and **Abort** (two clicks,
|
||
* `confirm: true`), and conflicted files get their own group with Keep mine /
|
||
* Take theirs / Mark resolved instead of stage — staging one blind would mark
|
||
* it resolved.
|
||
*
|
||
* The commit is JetBrains' (PhpStorm's Commit window without its staging
|
||
* area): Commit takes exactly the TICKED files' working-tree content
|
||
* (`paths`, `git commit --only`), whatever the index holds. A tracked change
|
||
* arrives ticked, an Unversioned one unticked, a never-commit one (this
|
||
* browser's list) never by default; Commit and Push pushes after it. A file's
|
||
* menu adds Commit File…, Jump to Source, Copy as Patch, Add to VCS and Add to
|
||
* .gitignore (`ignore`, untracked only). While a merge is open the box is
|
||
* hidden: a partial commit cannot finish one, Complete does.
|
||
*
|
||
* History rewrites through its commit menu: Revert (any commit), Undo commit
|
||
* (the top one; the server refuses a pushed one) and, while another branch's
|
||
* history is shown, Cherry-pick onto this branch. The commit row's Amend box
|
||
* folds the ticked files into the last commit (none ticked: rewords it). All
|
||
* four ask first (`confirmDialog`).
|
||
*
|
||
* "vs base" compares against the lane's base, else the repo's default branch
|
||
* (`git.base`), and is hidden when there is none or the lane IS on it.
|
||
*
|
||
* A diff line's number opens a comment box (LaneDiffView `onComment`); the
|
||
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane
|
||
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter;
|
||
* a toast says which tab took it, and a console not on screen takes nothing.
|
||
* A selection in the editor offers Add to Chat, which pastes ` @<path>#La-b`
|
||
* (Claude Code's line-range mention — without the `L` it attaches the whole
|
||
* file) the same way.
|
||
*
|
||
* An unsaved whole-file edit is never dropped silently: Back, another file,
|
||
* Cancel and a quick-open pick all ask once (`confirmDiscard`), closing or
|
||
* reloading the browser tab asks the browser's own question, and
|
||
* `onDirtyChange` tells the Workspace, which asks before closing the panel,
|
||
* switching its tab, or moving it to another workspace or session. Two-click
|
||
* actions (discard, a hunk's Undo, Abort) disarm after 3 s (`useArmed`).
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
ChevronDown,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronUp,
|
||
ChevronsDownUp,
|
||
ChevronsUpDown,
|
||
Check,
|
||
Download,
|
||
Code2,
|
||
Columns2,
|
||
Eye,
|
||
FileText,
|
||
FoldVertical,
|
||
Folder,
|
||
Minus,
|
||
Pencil,
|
||
Plus,
|
||
RefreshCw,
|
||
Rows3,
|
||
Sparkles,
|
||
Trash2,
|
||
UnfoldVertical,
|
||
FolderOpen,
|
||
} from "lucide-react";
|
||
import { api, gitTarget } from "../../lib/api";
|
||
import { pasteToLane } from "../../lib/panePaste";
|
||
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
|
||
import { copyText } from "../../lib/clipboard";
|
||
import { isSubmitEnter } from "../../lib/keys";
|
||
import { toast } from "../../lib/toast";
|
||
import { useArmed } from "../../hooks/useArmed";
|
||
import { useSpin } from "../../hooks/useSpin";
|
||
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
||
import type { BlockAction, EditorBlock } from "./DiffEditor";
|
||
import {
|
||
LaneDiffView,
|
||
LaneFileView,
|
||
changeBlocks,
|
||
langFor,
|
||
type DiffLayout,
|
||
type LaneDiffChange,
|
||
} from "./LaneDiffView";
|
||
import { LaneConflictView } from "./LaneConflictView";
|
||
import { FileExplorer } from "./FileExplorer";
|
||
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 { dragProjectPath } from "../../lib/promptAttach";
|
||
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
|
||
import type {
|
||
LaneChangeFile,
|
||
LaneChangeRange,
|
||
LaneChanges,
|
||
LaneBlame,
|
||
LaneCommit,
|
||
LaneDiffHunk,
|
||
LaneTreeEntry,
|
||
} from "../../lib/types";
|
||
|
||
/** How often the overview re-reads. Summary only — no patch text. */
|
||
const OVERVIEW_REFRESH_MS = 15_000;
|
||
/** Context steps the expand button walks through, then the whole file. */
|
||
const CONTEXT_STEPS: readonly [number, ...number[]] = [3, 12, 40, 5000];
|
||
|
||
const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
|
||
|
||
type Mode = "overview" | "review" | "file" | "history" | "browse";
|
||
|
||
/** Colour per porcelain status letter, matching LaneGitHeader's file panel. */
|
||
/** JetBrains' VCS colours: unversioned orange, added green, deleted grey,
|
||
* modified (and renamed) blue. */
|
||
function statusClass(status: string, untracked: boolean) {
|
||
if (untracked) return "text-kept";
|
||
const code = status.trim()[0];
|
||
if (code === "A") return "text-status-success";
|
||
if (code === "D") return "text-fg-muted";
|
||
return "text-diff-mod";
|
||
}
|
||
|
||
/** `+12 −3`, with the halves coloured and a zero side dropped. A binary file
|
||
* has no line delta at all, so it says `bin` rather than leaving the row's
|
||
* right edge blank, which reads as "changed by nothing". */
|
||
function Delta({
|
||
added,
|
||
deleted,
|
||
binary,
|
||
}: {
|
||
added: number | null;
|
||
deleted: number | null;
|
||
binary?: boolean;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
if (binary)
|
||
return <span className="shrink-0 font-mono text-[10px] text-fg-muted">{t("changes.bin")}</span>;
|
||
return (
|
||
<span className="shrink-0 font-mono text-[10px]">
|
||
{added ? <span className="text-status-success">+{added}</span> : null}
|
||
{added && deleted ? " " : null}
|
||
{deleted ? <span className="text-status-danger">−{deleted}</span> : null}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** 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 }) {
|
||
const { t } = useTranslation("lanes");
|
||
const [failed, setFailed] = useState(false);
|
||
useEffect(() => setFailed(false), [src]);
|
||
return (
|
||
<div
|
||
data-testid="lane-file-image"
|
||
className="flex min-h-0 flex-1 items-center justify-center overflow-auto rounded border border-border-light bg-surface-0 p-4"
|
||
>
|
||
{failed ? (
|
||
<p className="text-[11px] text-fg-muted">{t("changes.imageFailed")}</p>
|
||
) : (
|
||
<img
|
||
src={src}
|
||
alt={path}
|
||
onError={() => setFailed(true)}
|
||
className="max-h-full max-w-full object-contain"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Why a whole-file read came back without an edit stamp (`sha`): the i18n
|
||
* key naming it, so the reader is never left with a button that does nothing. */
|
||
function noEditReason(text: { binary?: boolean; truncated: boolean }) {
|
||
if (text.binary) return "changes.noEditBinary";
|
||
if (text.truncated) return "changes.noEditTooBig";
|
||
return "changes.noEdit";
|
||
}
|
||
|
||
/** The explorer's decoration for a porcelain code: one letter + JetBrains'
|
||
* colour (U unversioned orange, A added green, D deleted grey, R/M blue,
|
||
* ! conflicted red). */
|
||
function treeMark(code: string): { letter: string; tone: string } {
|
||
// The tint fills the whole row, not just the letter, so a changed file
|
||
// stands out of a long tree at a glance.
|
||
if (code === "??") return { letter: "U", tone: "text-kept bg-kept/10" };
|
||
// A merge conflict (git's unmerged codes) before A/D: `AA`/`DU` are not adds or deletes.
|
||
if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(code))
|
||
return { letter: "!", tone: "text-status-danger bg-status-danger/10" };
|
||
if (code.includes("A")) return { letter: "A", tone: "text-status-success bg-status-success/10" };
|
||
if (code.includes("D")) return { letter: "D", tone: "text-fg-muted" };
|
||
if (code.includes("R")) return { letter: "R", tone: "text-diff-mod bg-diff-mod/10" };
|
||
return { letter: "M", tone: "text-diff-mod bg-diff-mod/10" };
|
||
}
|
||
|
||
/**
|
||
* One folder level of the browse tree, rendered in place and recursing into
|
||
* every expanded child — so opening a nested file no longer means navigating
|
||
* away from its siblings.
|
||
*/
|
||
function TreeNodes({
|
||
path,
|
||
depth,
|
||
children,
|
||
expanded,
|
||
onToggleDir,
|
||
onOpenFile,
|
||
onMenu,
|
||
active,
|
||
}: {
|
||
path: string;
|
||
depth: number;
|
||
children: Record<string, LaneTreeEntry[]>;
|
||
expanded: Set<string>;
|
||
onToggleDir: (path: string) => void;
|
||
onOpenFile: (path: string) => void;
|
||
/** Right-click on an entry. */
|
||
onMenu?: (e: React.MouseEvent, entry: LaneTreeEntry) => void;
|
||
/** The file open beside the tree, drawn selected. */
|
||
active?: string | null;
|
||
}) {
|
||
const entries = children[path];
|
||
if (!entries) return null;
|
||
return (
|
||
<>
|
||
{entries.map((e) => {
|
||
const mark = e.status ? treeMark(e.status) : null;
|
||
return (
|
||
<li key={e.path}>
|
||
<button
|
||
type="button"
|
||
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
||
data-tree-ignored={e.ignored || undefined}
|
||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
||
draggable
|
||
onDragStart={(ev) => dragProjectPath(ev, e.dir ? `${e.path}/` : e.path)}
|
||
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 ${
|
||
e.ignored
|
||
? "text-fg-muted opacity-70"
|
||
: mark
|
||
? mark.tone
|
||
: e.changed
|
||
? "text-diff-mod"
|
||
: "text-fg-primary"
|
||
} ${active === e.path ? "!bg-surface-4" : ""}`}
|
||
>
|
||
{e.dir ? (
|
||
expanded.has(e.path) ? (
|
||
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
|
||
) : (
|
||
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
|
||
)
|
||
) : (
|
||
<span className="w-3 shrink-0" />
|
||
)}
|
||
{e.dir ? (
|
||
<Folder className="h-3 w-3 shrink-0 text-accent" />
|
||
) : (
|
||
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
|
||
)}
|
||
<span
|
||
className={`min-w-0 flex-1 truncate ${e.status?.includes("D") ? "line-through" : ""}`}
|
||
>
|
||
{e.name}
|
||
</span>
|
||
{mark ? (
|
||
<span className="shrink-0 pr-1 font-semibold" title={e.status}>
|
||
{mark.letter}
|
||
</span>
|
||
) : e.changed ? (
|
||
// A folder only says "something in here" — the dot, not a letter.
|
||
<span className="mr-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-diff-mod" />
|
||
) : null}
|
||
</button>
|
||
{e.dir && expanded.has(e.path) && (
|
||
<ul>
|
||
<TreeNodes
|
||
path={e.path}
|
||
depth={depth + 1}
|
||
children={children}
|
||
expanded={expanded}
|
||
onToggleDir={onToggleDir}
|
||
onOpenFile={onOpenFile}
|
||
onMenu={onMenu}
|
||
active={active}
|
||
/>
|
||
</ul>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function IconButton({
|
||
label,
|
||
onClick,
|
||
disabled,
|
||
danger,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
onClick: () => void;
|
||
disabled?: boolean;
|
||
danger?: boolean;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
aria-label={label}
|
||
title={label}
|
||
className={`shrink-0 rounded p-0.5 disabled:opacity-40 ${
|
||
danger ? "text-fg-muted hover:text-status-danger" : "text-fg-muted hover:text-fg-primary"
|
||
}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/** A hunk's Undo/Keep chip in its review bar: labelled, so the two read apart
|
||
* at a glance instead of as two grey glyphs. */
|
||
const HUNK_CHIP =
|
||
"flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] disabled:cursor-not-allowed disabled:opacity-40";
|
||
|
||
/** The git change block an editor block is, by its line span on both sides;
|
||
* none when the editor's diff and git's split the change differently. */
|
||
export function gitChangeFor(f: LaneChangeFile, b: EditorBlock): LaneDiffChange | undefined {
|
||
return (f.hunks ?? []).flatMap(changeBlocks).find((c) => {
|
||
const lines = c.hunk.lines.slice(c.firstLine, c.lastLine + 1);
|
||
const del = lines.filter((l) => l.type === "del");
|
||
const add = lines.filter((l) => l.type === "add");
|
||
return (
|
||
del.length === b.oldCount &&
|
||
add.length === b.newCount &&
|
||
(!del.length || del[0]!.oldLine === b.oldFrom) &&
|
||
(!add.length || add[0]!.newLine === b.newFrom)
|
||
);
|
||
});
|
||
}
|
||
|
||
type FileAct = (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
|
||
|
||
/** A work-range file's stage / unstage / discard buttons — the overview row's
|
||
* and the review header's. */
|
||
function FileActions({
|
||
f,
|
||
busy,
|
||
armed,
|
||
onAct,
|
||
onArm,
|
||
vcs = false,
|
||
}: {
|
||
f: LaneChangeFile;
|
||
busy: boolean;
|
||
armed: boolean;
|
||
onAct: FileAct;
|
||
onArm: (path: string) => void;
|
||
/** The overview's JetBrains rows: no index moves, only Add to VCS for an
|
||
* untracked file — the checkbox is what picks a file for the commit. */
|
||
vcs?: boolean;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
return (
|
||
<span className="flex shrink-0 items-center gap-0.5">
|
||
{f.staged && !vcs ? (
|
||
<IconButton
|
||
label={t("changes.unstage")}
|
||
disabled={busy}
|
||
onClick={() => onAct("unstage", f.path)}
|
||
>
|
||
<Minus className="h-3 w-3" />
|
||
</IconButton>
|
||
) : null}
|
||
{(vcs ? f.untracked : f.unstaged || f.untracked) ? (
|
||
<IconButton
|
||
label={t(vcs ? "changes.addToVcs" : "changes.stage")}
|
||
disabled={busy}
|
||
onClick={() => onAct("stage", f.path)}
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
</IconButton>
|
||
) : null}
|
||
{/* Two clicks, always: the first arms the row, the second is the only
|
||
one that reaches the server (which itself refuses the call without
|
||
`confirm: true`). Armed, the icon becomes a red word, so the second
|
||
click is asked for where the eye already is. */}
|
||
{armed ? (
|
||
<button
|
||
type="button"
|
||
data-testid="lane-changes-discard-confirm"
|
||
aria-label={t("changes.discardConfirm")}
|
||
title={t("changes.discardConfirm")}
|
||
disabled={busy}
|
||
onClick={() => onAct("discard", f.path, true)}
|
||
className={`${HUNK_CHIP} border-status-danger bg-status-danger/20 text-status-danger`}
|
||
>
|
||
{t("changes.discardShortConfirm")}
|
||
</button>
|
||
) : (
|
||
<IconButton
|
||
label={t("changes.discard")}
|
||
disabled={busy}
|
||
danger
|
||
onClick={() => onArm(f.path)}
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</IconButton>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** A commit checkbox: ticked, unticked or — for a folder or a group with
|
||
* some files ticked — indeterminate, which a click clears. */
|
||
function TickBox({
|
||
state,
|
||
label,
|
||
onChange,
|
||
}: {
|
||
state: boolean | "some";
|
||
label: string;
|
||
onChange: (on: boolean) => void;
|
||
}) {
|
||
return (
|
||
<input
|
||
type="checkbox"
|
||
ref={(el) => {
|
||
if (el) el.indeterminate = state === "some";
|
||
}}
|
||
checked={state === true}
|
||
onChange={() => onChange(state === false)}
|
||
onClick={(e) => e.stopPropagation()}
|
||
aria-label={label}
|
||
title={label}
|
||
className="h-3 w-3 shrink-0 cursor-pointer self-center"
|
||
/>
|
||
);
|
||
}
|
||
|
||
/** A folder's or group's TickBox state from its files' own. */
|
||
function tickState(
|
||
files: LaneChangeFile[],
|
||
fileTick: (path: string) => boolean | "some"
|
||
): boolean | "some" {
|
||
const states = files.map((f) => fileTick(f.path));
|
||
if (states.every((x) => x === true)) return files.length ? true : false;
|
||
return states.every((x) => x === false) ? false : "some";
|
||
}
|
||
|
||
const keysOf = new WeakMap<LaneDiffHunk[], Map<LaneDiffChange, string>>();
|
||
|
||
/** Each change block of a file, keyed by its content (its -/+ lines) and how
|
||
* many identical blocks came before it — so an unticked block stays
|
||
* unticked across a re-read at another context or an edit elsewhere in the
|
||
* file, which both move its hunk header. */
|
||
export function blockKeys(hunks: LaneDiffHunk[]): Map<LaneDiffChange, string> {
|
||
const hit = keysOf.get(hunks);
|
||
if (hit) return hit;
|
||
const seen = new Map<string, number>();
|
||
const out = new Map<LaneDiffChange, string>();
|
||
for (const c of hunks.flatMap(changeBlocks)) {
|
||
const sig = c.hunk.lines
|
||
.slice(c.firstLine, c.lastLine + 1)
|
||
.map((l) => `${l.type === "add" ? "+" : "-"}${l.text}`)
|
||
.join("\n");
|
||
const n = seen.get(sig) ?? 0;
|
||
seen.set(sig, n + 1);
|
||
out.set(c, `${n}\n${sig}`);
|
||
}
|
||
keysOf.set(hunks, out);
|
||
return out;
|
||
}
|
||
|
||
/** One file's diff as a unified patch (Copy as Patch). ponytail: git's
|
||
* "\ No newline at end of file" markers are not in the parsed hunks, so a
|
||
* file missing its last newline applies with one; read `git diff` raw if
|
||
* that ever matters. */
|
||
function toPatch(f: LaneChangeFile): string {
|
||
const added = f.untracked || f.status.includes("A");
|
||
const deleted = f.status.includes("D");
|
||
return [
|
||
`diff --git a/${f.path} b/${f.path}`,
|
||
`--- ${added ? "/dev/null" : `a/${f.path}`}`,
|
||
`+++ ${deleted ? "/dev/null" : `b/${f.path}`}`,
|
||
...(f.hunks ?? []).flatMap((h) => [
|
||
h.header,
|
||
...h.lines.map((l) => `${l.type === "add" ? "+" : l.type === "del" ? "-" : " "}${l.text}`),
|
||
]),
|
||
"",
|
||
].join("\n");
|
||
}
|
||
|
||
type Resolve = (path: string, side: "ours" | "theirs" | "done") => void;
|
||
|
||
/** A conflicted file's three ways out of the conflict. */
|
||
function ConflictActions({
|
||
f,
|
||
busy,
|
||
onResolve,
|
||
}: {
|
||
f: LaneChangeFile;
|
||
busy: boolean;
|
||
onResolve: Resolve;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
return (
|
||
<span className="flex shrink-0 items-center gap-0.5">
|
||
{(["ours", "theirs"] as const).map((side) => (
|
||
<button
|
||
key={side}
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => onResolve(f.path, side)}
|
||
title={t(side === "ours" ? "changes.takeOursHint" : "changes.takeTheirsHint")}
|
||
className={`shrink-0 rounded border px-1 text-[10px] disabled:opacity-40 ${
|
||
side === "ours"
|
||
? "border-status-success/40 text-status-success hover:bg-status-success/10"
|
||
: "border-accent/40 text-accent hover:bg-accent/10"
|
||
}`}
|
||
>
|
||
{t(side === "ours" ? "changes.takeOurs" : "changes.takeTheirs")}
|
||
</button>
|
||
))}
|
||
<IconButton
|
||
label={t("changes.markResolved")}
|
||
disabled={busy}
|
||
onClick={() => onResolve(f.path, "done")}
|
||
>
|
||
<Check className="h-3 w-3" />
|
||
</IconButton>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** One overview row. At module scope on purpose: declared inside the panel it
|
||
* was a new component type every render, so each poll and each keystroke in
|
||
* the commit box remounted every row (focus dropped to <body>). */
|
||
function ChangeRow({
|
||
f,
|
||
range,
|
||
busy,
|
||
armed,
|
||
onOpen,
|
||
onAct,
|
||
onArm,
|
||
onResolve,
|
||
onMenu,
|
||
name,
|
||
depth = 0,
|
||
ticked,
|
||
onTick,
|
||
}: {
|
||
f: LaneChangeFile;
|
||
/** In the commit: the row's checkbox (absent = no checkbox). */
|
||
ticked?: boolean | "some";
|
||
onTick?: (path: string, on: boolean) => void;
|
||
/** In the folder tree: the file's own name, and how deep it sits. */
|
||
name?: string;
|
||
depth?: number;
|
||
range: LaneChangeRange;
|
||
onResolve: Resolve;
|
||
onMenu: (e: React.MouseEvent, f: LaneChangeFile) => void;
|
||
busy: boolean;
|
||
/** This row's discard has been clicked once and awaits the second click. */
|
||
armed: boolean;
|
||
onOpen: (path: string) => void;
|
||
onAct: FileAct;
|
||
onArm: (path: string) => void;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
return (
|
||
<li
|
||
className="group flex items-baseline gap-1"
|
||
// A file has no chevron: reserve its 1rem so the letter lines up
|
||
// with a sibling folder's icon, one step inside the parent folder.
|
||
style={{ paddingLeft: `${depth + 1}rem` }}
|
||
onContextMenu={(e) => onMenu(e, f)}
|
||
>
|
||
{onTick && (
|
||
<TickBox
|
||
state={ticked ?? false}
|
||
label={t("changes.tickFile", { path: f.path })}
|
||
onChange={(on) => onTick(f.path, on)}
|
||
/>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpen(f.path)}
|
||
draggable
|
||
onDragStart={(ev) => dragProjectPath(ev, f.path)}
|
||
className="flex min-w-0 flex-1 items-baseline gap-1.5 rounded px-1 py-0.5 text-left hover:bg-surface-2"
|
||
>
|
||
<span
|
||
className={`w-3 shrink-0 font-mono text-[11px] font-semibold ${statusClass(
|
||
f.status,
|
||
f.untracked
|
||
)}`}
|
||
>
|
||
{f.untracked ? "A" : f.status.trim()[0] || "M"}
|
||
</span>
|
||
<span
|
||
className={`min-w-0 flex-1 truncate font-mono text-[11px] ${statusClass(
|
||
f.status,
|
||
f.untracked
|
||
)}`}
|
||
title={f.path}
|
||
>
|
||
{name ?? f.path}
|
||
</span>
|
||
{f.conflicted && (
|
||
<span className="shrink-0 text-[10px] text-status-danger">{t("changes.conflicted")}</span>
|
||
)}
|
||
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
|
||
</button>
|
||
{range === "work" &&
|
||
(f.conflicted ? (
|
||
<ConflictActions f={f} busy={busy} onResolve={onResolve} />
|
||
) : (
|
||
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} vcs={!!onTick} />
|
||
))}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
/** One folder of the overview's change tree: its sub-folders (each a
|
||
* collapsible row with its file count), then its files. */
|
||
function ChangeTreeNodes({
|
||
dir,
|
||
depth,
|
||
group,
|
||
collapsed,
|
||
onToggle,
|
||
renderFile,
|
||
folderTick,
|
||
}: {
|
||
dir: ChangeTreeDir<LaneChangeFile>;
|
||
depth: number;
|
||
/** The overview group, so the same folder folds apart in each. */
|
||
group: string;
|
||
collapsed: Set<string>;
|
||
onToggle: (key: string) => void;
|
||
renderFile: (f: LaneChangeFile, name: string, depth: number) => React.ReactNode;
|
||
/** The folder row's checkbox, before its name. */
|
||
folderTick?: (files: LaneChangeFile[], path: string) => React.ReactNode;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
return (
|
||
<>
|
||
{dir.dirs.map((d) => {
|
||
const key = `${group}:${d.path}`;
|
||
const open = !collapsed.has(key);
|
||
return (
|
||
<li key={d.path}>
|
||
<div className="group flex items-center gap-1" style={{ paddingLeft: `${depth}rem` }}>
|
||
{folderTick?.(treeFiles(d), d.path)}
|
||
<button
|
||
type="button"
|
||
aria-expanded={open}
|
||
onClick={() => onToggle(key)}
|
||
title={d.path}
|
||
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
|
||
>
|
||
{open ? (
|
||
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
|
||
) : (
|
||
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
|
||
)}
|
||
<Folder className="h-3 w-3 shrink-0 text-accent" />
|
||
<span className="min-w-0 truncate">{d.name}</span>
|
||
<span className="shrink-0 font-sans text-[10px] text-fg-muted">
|
||
{t("changes.fileCount", { count: d.count })}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
{open && (
|
||
<ul className="space-y-0.5">
|
||
<ChangeTreeNodes
|
||
dir={d}
|
||
depth={depth + 1}
|
||
group={group}
|
||
collapsed={collapsed}
|
||
onToggle={onToggle}
|
||
renderFile={renderFile}
|
||
folderTick={folderTick}
|
||
/>
|
||
</ul>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
{dir.files.map(({ name, file }) => renderFile(file, name, depth))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** History's page size, and the most the server reads (`listCommits`). */
|
||
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,
|
||
source,
|
||
onNewLane,
|
||
full = false,
|
||
wide = full,
|
||
onDirtyChange,
|
||
openRequest = null,
|
||
}: {
|
||
laneId: number;
|
||
/** Open this file (relative to the panel's folder); a new `nonce` asks again. */
|
||
openRequest?: { path: string; nonce: number } | null;
|
||
/** The whole-file editor holds unsaved text (true) or not (false). */
|
||
onDirtyChange?: (dirty: boolean) => void;
|
||
/** Show and act on that session's own worktree (`claude --worktree`)
|
||
* instead of the workspace's folder. The Workspace keys the panel on it,
|
||
* so a different session is a fresh panel. */
|
||
session?: string;
|
||
/** Show and act on that extra folder's repo instead (`lane_sources` id);
|
||
* wins over `session`, and is keyed on like it. */
|
||
source?: number;
|
||
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
||
onNewLane?: (branch?: string) => void;
|
||
/** 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:
|
||
// a stage, a discard or a commit changes the counts the header shows.
|
||
const [git, refreshGit] = useLaneGitFacts(laneId, session, source);
|
||
// A merge, cherry-pick or revert is open: no new one starts from history.
|
||
const opOpen = !!(git?.available && git.merging);
|
||
// Every git read and write below goes to the same tree as those facts.
|
||
const target = gitTarget(laneId, session, source);
|
||
// A diff line's comment goes into this workspace's pane, as a paste.
|
||
const comment = useCallback(
|
||
(text: string) => {
|
||
const runId = pasteToLane(laneId, text);
|
||
if (runId) {
|
||
const slot = Number(/-([1-9])$/.exec(runId)?.[1] ?? 0);
|
||
toast.success(t("changes.commentPasted", { tab: slot + 1 }));
|
||
}
|
||
return runId !== null;
|
||
},
|
||
[laneId, t]
|
||
);
|
||
// Bumped by Refresh and by a git bar action: a pull or a switch moves
|
||
// history and the tree too, not just the diff `load` re-reads.
|
||
const [reread, setReread] = useState(0);
|
||
const [range, setRange] = useState<LaneChangeRange>("work");
|
||
const [mode, setMode] = useState<Mode>("overview");
|
||
const [openFile, setOpenFile] = useState<string | null>(null);
|
||
// An image opens as a picture, never as text or a binary diff.
|
||
const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null;
|
||
const [sha, setSha] = useState<string | null>(null);
|
||
const [context, setContext] = useState(CONTEXT_STEPS[0]);
|
||
// 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);
|
||
useEffect(() => setMdPreview(false), [openFile]);
|
||
const [data, setData] = useState<LaneChanges | null>(null);
|
||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
||
// on first expand, and kept so re-collapsing doesn't re-fetch.
|
||
const [treeChildren, setTreeChildren] = useState<Record<string, LaneTreeEntry[]>>({});
|
||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||
const [query, setQuery] = useState("");
|
||
const [hits, setHits] = useState<string[] | null>(null);
|
||
const [text, setText] = useState<{
|
||
path: string;
|
||
lines: string[];
|
||
truncated: boolean;
|
||
binary?: boolean;
|
||
sha?: string;
|
||
} | null>(null);
|
||
// The whole-file editor's text while it is open; null = viewing.
|
||
const [draft, setDraft] = useState<string | null>(null);
|
||
const editWanted = useRef(false);
|
||
// 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;
|
||
setDraft(text.lines.join("\n"));
|
||
};
|
||
useEffect(() => {
|
||
if (!editWanted.current || !text) return;
|
||
editWanted.current = false;
|
||
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)));
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [text]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
// Two-click arming that disarms by itself after 3 s: the armed path, the
|
||
// armed hunk (`${path}\n${header}\n${block}`) and the armed merge abort.
|
||
const discardArm = useArmed();
|
||
const confirmDiscard = discardArm.armed;
|
||
const setConfirmDiscard = (path: string | null) =>
|
||
path ? discardArm.arm(path) : discardArm.disarm();
|
||
const abortArm = useArmed();
|
||
const armedAbort = abortArm.armed !== null;
|
||
const setArmedAbort = (on: boolean) => (on ? abortArm.arm("abort") : abortArm.disarm());
|
||
// A conflicted file opens in the conflict view; true = the plain editor.
|
||
const [rawEdit, setRawEdit] = useState(false);
|
||
// The hunk whose Undo was clicked once: `${path}\n${header}\n${block}`.
|
||
const hunkArm = useArmed();
|
||
const armedHunk = hunkArm.armed;
|
||
const setArmedHunk = (key: string | null) => (key ? hunkArm.arm(key) : hunkArm.disarm());
|
||
// Files folded shut in review mode. A change-set is read file by file, and
|
||
// a file already read is in the way of the next one — so the set is what is
|
||
// COLLAPSED, and a file arriving later is open by default.
|
||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||
// The overview tree's folded groups and folders (`<group>:<folder path>`);
|
||
// everything starts open.
|
||
const [treeCollapsed, setTreeCollapsed] = useState<Set<string>>(new Set());
|
||
const toggleTreeDir = (key: string) =>
|
||
setTreeCollapsed((prev) => {
|
||
const next = new Set(prev);
|
||
if (!next.delete(key)) next.add(key);
|
||
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.
|
||
const scroller = useRef<HTMLDivElement>(null);
|
||
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
|
||
const pendingScroll = useRef<number | null>(null);
|
||
const [message, setMessage] = useState("");
|
||
// The commit row amends the last commit instead of making a new one.
|
||
const [amend, setAmend] = useState(false);
|
||
// The last commit is already on its upstream: the server refuses to amend
|
||
// it (EPUSHED), so the box is not offered — asking first and failing after
|
||
// the confirm only costs the user a click.
|
||
const headPushed = !!git?.available && !!git.upstream && git.ahead === 0 && !!git.head;
|
||
useEffect(() => {
|
||
if (headPushed) setAmend(false);
|
||
}, [headPushed]);
|
||
// The files the commit takes (JetBrains' checkboxes). A path listed for the
|
||
// first time arrives with its default; `seenPaths` is the last full list,
|
||
// so a poll never re-ticks a file the user unticked.
|
||
const [ticked, setTicked] = useState<Set<string>>(new Set());
|
||
// A ticked file's change blocks left OUT of the commit (each block's own
|
||
// checkbox in the diff), by `blockKeys`; absent = the whole file.
|
||
const [excluded, setExcluded] = useState<Map<string, Set<string>>>(new Map());
|
||
// The last full list (overview or review), in order: Next/Previous file
|
||
// walks it while one file is open and `data` holds that file alone.
|
||
const [listed, setListed] = useState<string[]>([]);
|
||
// History of one file (Show History), not the branch; null = the branch.
|
||
const [historyFile, setHistoryFile] = useState<string | null>(null);
|
||
// Annotate: who last changed each line of the open file.
|
||
const [annotate, setAnnotate] = useState(false);
|
||
const [blame, setBlame] = useState<Extract<LaneBlame, { available: true }> | null>(null);
|
||
const editorNav = useRef<((dir: 1 | -1) => void) | null>(null);
|
||
const seenPaths = useRef<Set<string> | null>(null);
|
||
const messageRef = useRef<HTMLInputElement>(null);
|
||
// History of another branch (where Cherry-pick finds its commits); null =
|
||
// this branch's own.
|
||
const [historyRef, setHistoryRef] = useState<string | null>(null);
|
||
const [historyBranches, setHistoryBranches] = useState<string[]>([]);
|
||
const [suggesting, setSuggesting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Every diff read gets a ticket; only the newest may land. A lane switch (or
|
||
// a mode/range change) re-issues the read, and without this a slow reply for
|
||
// the previous lane painted its files — and its discard buttons — over this one.
|
||
const loadSeq = useRef(0);
|
||
// The same guard for the side reads (history, tree, whole file), which only
|
||
// race across a lane switch: a reply for a lane no longer shown is dropped.
|
||
const laneNow = useRef(laneId);
|
||
laneNow.current = laneId;
|
||
const forLane =
|
||
<T,>(id: number, fn: (v: T) => void) =>
|
||
(v: T) => {
|
||
if (laneNow.current === id) fn(v);
|
||
};
|
||
const load = useCallback(async () => {
|
||
const seq = ++loadSeq.current;
|
||
setLoading(true);
|
||
try {
|
||
const result = await api.lanes.changes(target, {
|
||
range,
|
||
...(range === "commit" && sha ? { sha } : {}),
|
||
...(mode === "file" && openFile ? { path: openFile, context } : {}),
|
||
...(mode === "review" ? { full: true } : {}),
|
||
});
|
||
if (seq !== loadSeq.current) return;
|
||
setData(result);
|
||
setError(null);
|
||
} catch (err) {
|
||
if (seq !== loadSeq.current) return;
|
||
// A tracked file opened from Files that has nothing to diff: a normal
|
||
// state, not a fault. The whole-file view is the answer; no banner.
|
||
if (mode === "file" && (err as { code?: string })?.code === "ENOFILECHANGE") {
|
||
setData({
|
||
available: true,
|
||
range,
|
||
base: null,
|
||
baseRef: null,
|
||
sha,
|
||
context,
|
||
files: [],
|
||
truncated: false,
|
||
});
|
||
setError(null);
|
||
return;
|
||
}
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (seq === loadSeq.current) setLoading(false);
|
||
}
|
||
}, [laneId, range, sha, mode, openFile, context]);
|
||
|
||
useEffect(() => {
|
||
if (mode === "history" || mode === "browse") return;
|
||
void load();
|
||
// Only the cheap shape polls; see the file header.
|
||
if (mode !== "overview") return;
|
||
const timer = window.setInterval(
|
||
() => document.visibilityState === "visible" && void load(),
|
||
OVERVIEW_REFRESH_MS
|
||
);
|
||
return () => window.clearInterval(timer);
|
||
}, [load, mode]);
|
||
|
||
// History and the file tree are their own reads, each fetched when its mode
|
||
// is entered rather than alongside the diff nobody is looking at.
|
||
// History reads HISTORY_PAGE commits, and Load more asks for the next page
|
||
// (up to the server's 200).
|
||
const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
|
||
const [historyLoading, setHistoryLoading] = useState(false);
|
||
useEffect(() => {
|
||
if (mode !== "history") return;
|
||
setHistoryLoading(true);
|
||
void api.lanes
|
||
.gitLog(target, {
|
||
limit: historyLimit,
|
||
...(historyRef ? { ref: historyRef } : {}),
|
||
...(historyFile ? { file: historyFile } : {}),
|
||
})
|
||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
|
||
}, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
|
||
useEffect(() => {
|
||
if (mode !== "history") return;
|
||
void api.lanes
|
||
.gitBranches(target)
|
||
.then(forLane(laneId, (r) => setHistoryBranches(r.branches)))
|
||
.catch(() => setHistoryBranches([]));
|
||
}, [mode, laneId, reread]);
|
||
|
||
// Re-run when a lane switch empties the cache too: the full-width sidebar
|
||
// stays across the switch, so the mode alone never changes.
|
||
const treeRootLoaded = "" in treeChildren;
|
||
useEffect(() => {
|
||
const wanted = full || (mode === "browse" && !query.trim());
|
||
if (!wanted || treeRootLoaded) return;
|
||
void api.lanes
|
||
.gitTree(target, "")
|
||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))))
|
||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||
// `treeChildren` itself is the cache this fills — a dep on it would loop.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [mode, laneId, query, reread, full, treeRootLoaded]);
|
||
|
||
// A folder's children are fetched lazily, the first time it is expanded.
|
||
const toggleDir = (path: string) => {
|
||
setExpandedDirs((prev) => {
|
||
const next = new Set(prev);
|
||
if (!next.delete(path)) next.add(path);
|
||
return next;
|
||
});
|
||
if (path in treeChildren) return;
|
||
void api.lanes
|
||
.gitTree(target, path)
|
||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
|
||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||
};
|
||
|
||
const [treeBusy, setTreeBusy] = useState(false);
|
||
|
||
// "Expand all" is ONE read of the whole tree: a request per folder meant a
|
||
// `git ls-files` per folder, seconds of waiting on a large repo.
|
||
const expandAllDirs = async () => {
|
||
const lane = laneId;
|
||
setTreeBusy(true);
|
||
try {
|
||
const { levels } = await api.lanes.gitTreeAll(target);
|
||
// A lane switch mid-read must not land lane A's tree in lane B.
|
||
if (laneNow.current !== lane) return;
|
||
setTreeChildren(levels);
|
||
setExpandedDirs(new Set(Object.keys(levels).filter(Boolean)));
|
||
} catch (err) {
|
||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setTreeBusy(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const q = query.trim();
|
||
if ((mode !== "browse" && !full) || !q) {
|
||
setHits(null);
|
||
return;
|
||
}
|
||
// `live` drops an answer that a newer query, a mode change or a lane
|
||
// switch has overtaken — a slow "main" must not overwrite "mainx".
|
||
let live = true;
|
||
const timer = window.setTimeout(() => {
|
||
void api.lanes
|
||
.gitLs(target, q)
|
||
.then((r) => live && setHits(r.paths))
|
||
.catch(() => live && setHits([]));
|
||
}, 150);
|
||
return () => {
|
||
live = false;
|
||
window.clearTimeout(timer);
|
||
};
|
||
}, [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 || editable) || !openFile) return;
|
||
void api.lanes
|
||
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
|
||
.then(
|
||
forLane(laneId, (r) =>
|
||
setText(
|
||
r.available
|
||
? {
|
||
path: r.path,
|
||
lines: r.lines,
|
||
truncated: r.truncated,
|
||
binary: r.binary,
|
||
sha: r.sha,
|
||
}
|
||
: null
|
||
)
|
||
)
|
||
)
|
||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||
}, [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]);
|
||
|
||
// A lane switch must not leave the previous lane's file open — its path
|
||
// almost certainly does not exist in the new one.
|
||
useEffect(() => {
|
||
setMode("overview");
|
||
setOpenFile(null);
|
||
setSha(null);
|
||
setRange("work");
|
||
setTreeChildren({});
|
||
setExpandedDirs(new Set());
|
||
setTreeBusy(false);
|
||
setBusy(false);
|
||
setError(null);
|
||
setCommits(null);
|
||
setData(null);
|
||
setDraft(null);
|
||
setCollapsed(new Set());
|
||
// An armed discard names a PATH, not a file in a lane — carried over, one
|
||
// click would discard the same path in the lane just switched to.
|
||
setConfirmDiscard(null);
|
||
// Same for a half-typed commit message: it describes the OTHER lane's work.
|
||
setMessage("");
|
||
setAmend(false);
|
||
setTicked(new Set());
|
||
setExcluded(new Map());
|
||
setListed([]);
|
||
setHistoryFile(null);
|
||
seenPaths.current = null;
|
||
setHistoryRef(null);
|
||
returnTo.current = null;
|
||
pendingScroll.current = null;
|
||
}, [laneId]);
|
||
// After the lane reset above: a workspace opens on its changes.
|
||
// Leaving the open file this way (the Workspace asked first when it was
|
||
// edited) drops it whole: a draft left behind would keep `dirty` true.
|
||
useEffect(() => {
|
||
if (full) return;
|
||
setMode("overview");
|
||
setOpenFile(null);
|
||
setWhole(false);
|
||
setDraft(null);
|
||
}, [laneId, full]);
|
||
|
||
// Restoring has to wait for the list to be TALL enough again: leaving a file
|
||
// re-fetches the mode it came from, so the first render back is an empty
|
||
// box that would swallow the scroll.
|
||
useLayoutEffect(() => {
|
||
const el = scroller.current;
|
||
const want = pendingScroll.current;
|
||
if (mode === "file" || want == null || !el) return;
|
||
if (el.scrollHeight - el.clientHeight < want) return;
|
||
el.scrollTop = want;
|
||
pendingScroll.current = null;
|
||
}, [mode, data]);
|
||
|
||
// Every way out of an open, edited file asks once; nothing else does.
|
||
const dirtyRef = useRef(false);
|
||
const confirmDrop = async () => !dirtyRef.current || (await askDiscard());
|
||
|
||
const showNow = (path: string, inRange: LaneChangeRange = range) => {
|
||
// Opening a file FROM a file (the review list is gone by then) must not
|
||
// overwrite the list position that is still waiting to be restored.
|
||
if (mode !== "file") {
|
||
returnTo.current = { mode, scroll: scroller.current?.scrollTop ?? 0 };
|
||
}
|
||
setOpenFile(path);
|
||
setContext(CONTEXT_STEPS[0]);
|
||
// 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(inRange, path, f);
|
||
setWhole(false);
|
||
setText(null);
|
||
setBaseText(null);
|
||
setDraft(null);
|
||
setRawEdit(false);
|
||
setMode("file");
|
||
};
|
||
|
||
const show = (path: string) => {
|
||
void confirmDrop().then((ok) => ok && showNow(path));
|
||
};
|
||
// A file named from outside the panel (a chat's "Edited N files" card).
|
||
useEffect(() => {
|
||
if (openRequest) show(openRequest.path);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per request
|
||
}, [openRequest?.nonce]);
|
||
// A file's history (Show History): the commits that touched it.
|
||
const showHistory = (path: string) => {
|
||
void confirmDrop().then((ok) => {
|
||
if (!ok) return;
|
||
setHistoryFile(path);
|
||
setHistoryRef(null);
|
||
setCommits(null);
|
||
setMode("history");
|
||
});
|
||
};
|
||
// A commit picked from a file's history: that file, as that commit changed it.
|
||
const openAtCommit = (path: string, commitSha: string) => {
|
||
setSha(commitSha);
|
||
setRange("commit");
|
||
showNow(path, "commit");
|
||
};
|
||
// Next / Previous file, in the list the file was opened from.
|
||
const fileAt = listed.indexOf(openFile ?? "");
|
||
const stepFile = (dir: 1 | -1) => {
|
||
const next = listed[fileAt + dir];
|
||
if (fileAt >= 0 && next) show(next);
|
||
};
|
||
// F7 / Shift+F7: the next / previous difference — the editor's own chunks,
|
||
// else the diff rows each change block starts on (`data-change-index`).
|
||
// ponytail: a windowed diff (500+ rows) keeps off-screen rows out of the
|
||
// DOM, so a jump there stops at the last rendered block.
|
||
const stepDiff = (dir: 1 | -1) => {
|
||
if (mode === "file" && editorNav.current) return editorNav.current(dir);
|
||
const root = scroller.current;
|
||
if (!root) return;
|
||
const top = root.getBoundingClientRect().top + 48;
|
||
const marks = [...root.querySelectorAll<HTMLElement>("[data-change-index]")].map((el) => ({
|
||
el,
|
||
y: el.getBoundingClientRect().top,
|
||
}));
|
||
const hit =
|
||
dir === 1
|
||
? marks.find((m) => m.y > top + 1)
|
||
: [...marks].reverse().find((m) => m.y < top - 1);
|
||
hit?.el.scrollIntoView?.({ block: "center" });
|
||
};
|
||
const toggleAnnotate = async () => {
|
||
if (annotate || !openFile) return setAnnotate(false);
|
||
setAnnotate(true);
|
||
const lane = laneId;
|
||
try {
|
||
const r = await api.lanes.gitBlame(target, openFile);
|
||
if (laneNow.current !== lane) return;
|
||
if (!r.available) {
|
||
setAnnotate(false);
|
||
toast.error(t("changes.noBlame"));
|
||
return;
|
||
}
|
||
setBlame(r);
|
||
} catch (err) {
|
||
if (laneNow.current !== lane) return;
|
||
setAnnotate(false);
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
}
|
||
};
|
||
useEffect(() => {
|
||
setAnnotate(false);
|
||
setBlame(null);
|
||
}, [openFile, laneId]);
|
||
|
||
const goBackNow = () => {
|
||
const back = returnTo.current;
|
||
pendingScroll.current = back?.scroll ?? 0;
|
||
returnTo.current = null;
|
||
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
|
||
setOpenFile(null);
|
||
setWhole(false);
|
||
setDraft(null);
|
||
};
|
||
const goBack = () => {
|
||
void confirmDrop().then((ok) => ok && goBackNow());
|
||
};
|
||
|
||
// Save the editor over exactly the bytes it opened (`sha`): a file the agent
|
||
// rewrote meanwhile comes back 409 ESTALE and the draft stays for a retry.
|
||
const saveDraft = async () => {
|
||
if (draft === null || !text?.sha) return;
|
||
const lane = laneId;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const r = await api.lanes.gitSaveFile(target, text.path, draft, text.sha);
|
||
if (laneNow.current !== lane) return;
|
||
if (r.available) {
|
||
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
|
||
}
|
||
// Saved, still editing: the editor stays on the text just written.
|
||
setDraft(r.available ? r.lines.join("\n") : null);
|
||
refreshGit();
|
||
await load();
|
||
} catch (err) {
|
||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setBusy(false);
|
||
}
|
||
};
|
||
|
||
// An action that resolves after a lane switch belongs to the lane left: its
|
||
// `load` is the pre-switch one and would take a fresh ticket and paint the
|
||
// old lane's files here, so everything after the await is dropped.
|
||
const act = async (
|
||
action: "stage" | "unstage" | "discard" | "ignore",
|
||
path: string | string[],
|
||
confirm?: boolean
|
||
) => {
|
||
const lane = laneId;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const paths = Array.isArray(path) ? path : [path];
|
||
await api.lanes.gitAction(target, action, { paths, ...(confirm ? { confirm } : {}) });
|
||
if (laneNow.current !== lane) return;
|
||
// Add to VCS puts the file in the commit, as JetBrains does.
|
||
if (action === "stage") setTicked((prev) => new Set([...prev, ...paths]));
|
||
setConfirmDiscard(null);
|
||
refreshGit();
|
||
await load();
|
||
} catch (err) {
|
||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setBusy(false);
|
||
}
|
||
};
|
||
|
||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||
const mergeAct = async (
|
||
action: "resolve" | "merge-commit" | "merge-abort",
|
||
body: Parameters<typeof api.lanes.gitAction>[2] = {}
|
||
) => {
|
||
const lane = laneId;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
await api.lanes.gitAction(target, action, body);
|
||
if (laneNow.current !== lane) return;
|
||
setArmedAbort(false);
|
||
refreshGit();
|
||
await load();
|
||
} catch (err) {
|
||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setBusy(false);
|
||
}
|
||
};
|
||
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
|
||
|
||
// Revert / cherry-pick / undo from the history's commit menu. A conflict is
|
||
// an operation that STARTED: the overview is where it gets resolved.
|
||
const historyAct = async (action: "cherry-pick" | "revert" | "undo-commit", c: LaneCommit) => {
|
||
// Every one of the three rewrites this branch's history, so each asks.
|
||
const branch = git?.available ? git.branch : "HEAD";
|
||
const ask =
|
||
action === "undo-commit"
|
||
? {
|
||
title: t("changes.undoCommitTitle"),
|
||
message: t("changes.undoCommitBody", { short: c.short, subject: c.subject }),
|
||
confirmLabel: t("changes.undoCommitConfirm"),
|
||
}
|
||
: action === "revert"
|
||
? {
|
||
title: t("changes.revertCommit"),
|
||
message: t("changes.revertConfirm", { short: c.short, subject: c.subject, branch }),
|
||
confirmLabel: t("changes.revertCommit"),
|
||
destructive: false,
|
||
}
|
||
: {
|
||
title: t("changes.cherryPick", { branch }),
|
||
message: t("changes.cherryPickConfirm", {
|
||
short: c.short,
|
||
subject: c.subject,
|
||
from: historyRef,
|
||
branch,
|
||
}),
|
||
confirmLabel: t("changes.cherryPick", { branch }),
|
||
destructive: false,
|
||
};
|
||
if (!(await confirmDialog(ask))) return;
|
||
const lane = laneId;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const r = await api.lanes.gitAction(
|
||
target,
|
||
action,
|
||
// The commit the dialog named: the server refuses an undo once HEAD
|
||
// is another one (ESTALE) — an agent may have committed meanwhile.
|
||
action === "undo-commit" ? { confirm: true, sha: c.sha } : { sha: c.sha }
|
||
);
|
||
if (laneNow.current !== lane) return;
|
||
if (r.conflicts?.length) {
|
||
toast.error(t("changes.opConflicts", { op: action, count: r.conflicts.length }));
|
||
} else if (action !== "undo-commit") {
|
||
toast.success(t("changes.opDone", { op: action }));
|
||
}
|
||
// Conflicts and an undone commit's staged changes are both work-range
|
||
// facts; a clean pick or revert is best seen in this branch's history.
|
||
setHistoryRef(null);
|
||
if (action === "undo-commit" || r.conflicts?.length) {
|
||
setRange("work");
|
||
setMode("overview");
|
||
}
|
||
void rereadAll();
|
||
} catch (err) {
|
||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setBusy(false);
|
||
}
|
||
};
|
||
|
||
const fileCtx = useContextMenu<LaneChangeFile>();
|
||
const treeCtx = useContextMenu<LaneTreeEntry>();
|
||
const commitCtx = useContextMenu<LaneCommit>();
|
||
const viewCommit = (sha: string) => {
|
||
setSha(sha);
|
||
setRange("commit");
|
||
setMode("review");
|
||
};
|
||
const copy = (text: string) => void copyText(text);
|
||
// A menu item: close its menu, then act.
|
||
const pick = (close: () => void, fn: () => void) => () => {
|
||
close();
|
||
fn();
|
||
};
|
||
const menuFile = fileCtx.menu?.item;
|
||
const menuArmed = !!menuFile && confirmDiscard === menuFile.path;
|
||
const menus = (
|
||
<>
|
||
{fileCtx.menu && menuFile && (
|
||
<ContextMenu
|
||
x={fileCtx.menu.x}
|
||
y={fileCtx.menu.y}
|
||
onClose={fileCtx.close}
|
||
testId="lane-changes-file-menu"
|
||
>
|
||
{range === "work" && !menuFile.conflicted && !opOpen && (
|
||
<>
|
||
<MenuItem
|
||
disabled={busy}
|
||
onSelect={pick(fileCtx.close, () => commitFile(menuFile.path))}
|
||
>
|
||
{t("changes.commitFile")}
|
||
</MenuItem>
|
||
<MenuSeparator />
|
||
</>
|
||
)}
|
||
<MenuItem onSelect={pick(fileCtx.close, () => show(menuFile.path))}>
|
||
{t("changes.openFile", { path: menuFile.path.split("/").pop() })}
|
||
</MenuItem>
|
||
{!menuFile.status.includes("D") && (
|
||
<MenuItem onSelect={pick(fileCtx.close, () => void jumpToSource(menuFile.path))}>
|
||
{t("changes.jumpToSource")}
|
||
</MenuItem>
|
||
)}
|
||
<MenuItem onSelect={pick(fileCtx.close, () => showHistory(menuFile.path))}>
|
||
{t("changes.showHistory")}
|
||
</MenuItem>
|
||
{range === "work" && menuFile.conflicted && (
|
||
<>
|
||
<MenuSeparator />
|
||
<MenuItem
|
||
disabled={busy}
|
||
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "ours"))}
|
||
>
|
||
{t("changes.takeOurs")}
|
||
</MenuItem>
|
||
<MenuItem
|
||
disabled={busy}
|
||
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "theirs"))}
|
||
>
|
||
{t("changes.takeTheirs")}
|
||
</MenuItem>
|
||
<MenuItem
|
||
disabled={busy}
|
||
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "done"))}
|
||
>
|
||
{t("changes.markResolved")}
|
||
</MenuItem>
|
||
</>
|
||
)}
|
||
{range === "work" && !menuFile.conflicted && (
|
||
<>
|
||
<MenuSeparator />
|
||
{menuFile.untracked && (
|
||
<>
|
||
<MenuItem
|
||
disabled={busy}
|
||
onSelect={pick(fileCtx.close, () => void act("stage", menuFile.path))}
|
||
>
|
||
{t("changes.addToVcs")}
|
||
</MenuItem>
|
||
<MenuItem
|
||
disabled={busy}
|
||
onSelect={pick(fileCtx.close, () => void act("ignore", menuFile.path))}
|
||
>
|
||
{t("changes.addToGitignore")}
|
||
</MenuItem>
|
||
</>
|
||
)}
|
||
{/* The index is not what Commit takes any more, but a hunk
|
||
staged from a review still sits there: the way back out. */}
|
||
{menuFile.staged && (
|
||
<MenuItem
|
||
disabled={busy}
|
||
onSelect={pick(fileCtx.close, () => void act("unstage", menuFile.path))}
|
||
>
|
||
{t("changes.unstage")}
|
||
</MenuItem>
|
||
)}
|
||
<MenuItem
|
||
danger
|
||
disabled={busy}
|
||
testId="lane-changes-menu-discard"
|
||
// Two clicks here too: the first arms (the row's trash
|
||
// shows it), only the second reaches the server.
|
||
onSelect={() =>
|
||
menuArmed
|
||
? pick(fileCtx.close, () => void act("discard", menuFile.path, true))()
|
||
: setConfirmDiscard(menuFile.path)
|
||
}
|
||
>
|
||
{menuArmed ? t("changes.discardConfirm") : t("changes.discard")}
|
||
</MenuItem>
|
||
</>
|
||
)}
|
||
<MenuSeparator />
|
||
<MenuItem onSelect={pick(fileCtx.close, () => copy(menuFile.path))}>
|
||
{t("changes.copyPath")}
|
||
</MenuItem>
|
||
{!menuFile.binary && (
|
||
<MenuItem onSelect={pick(fileCtx.close, () => void copyPatch(menuFile.path))}>
|
||
{t("changes.copyPatch")}
|
||
</MenuItem>
|
||
)}
|
||
<MenuSeparator />
|
||
<MenuItem onSelect={pick(fileCtx.close, () => void runReread())}>
|
||
{t("changes.refresh")}
|
||
</MenuItem>
|
||
</ContextMenu>
|
||
)}
|
||
{treeCtx.menu && (
|
||
<ContextMenu
|
||
x={treeCtx.menu.x}
|
||
y={treeCtx.menu.y}
|
||
onClose={treeCtx.close}
|
||
testId="lane-changes-tree-menu"
|
||
>
|
||
{!treeCtx.menu.item.dir && (
|
||
<>
|
||
<MenuItem
|
||
onSelect={pick(treeCtx.close, () => void openWhole(treeCtx.menu!.item.path))}
|
||
>
|
||
{t("changes.openFile", { path: treeCtx.menu.item.name })}
|
||
</MenuItem>
|
||
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
|
||
{t("changes.showHistory")}
|
||
</MenuItem>
|
||
<MenuItem
|
||
testId="lane-changes-menu-download"
|
||
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
|
||
>
|
||
{t("changes.download")}
|
||
</MenuItem>
|
||
</>
|
||
)}
|
||
{treeCtx.menu.item.dir && (
|
||
<MenuItem
|
||
testId="lane-changes-menu-upload"
|
||
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
|
||
>
|
||
{t("changes.uploadHere")}
|
||
</MenuItem>
|
||
)}
|
||
{treeCtx.menu.item.dir && (
|
||
<MenuItem
|
||
testId="lane-changes-menu-explorer"
|
||
onSelect={pick(treeCtx.close, () => setExplorer({ path: treeCtx.menu!.item.path }))}
|
||
>
|
||
{t("changes.openInExplorer")}
|
||
</MenuItem>
|
||
)}
|
||
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
|
||
{t("changes.copyPath")}
|
||
</MenuItem>
|
||
</ContextMenu>
|
||
)}
|
||
{commitCtx.menu && (
|
||
<ContextMenu
|
||
x={commitCtx.menu.x}
|
||
y={commitCtx.menu.y}
|
||
onClose={commitCtx.close}
|
||
testId="lane-changes-commit-menu"
|
||
>
|
||
<MenuItem onSelect={pick(commitCtx.close, () => viewCommit(commitCtx.menu!.item.sha))}>
|
||
{t("changes.viewCommit")}
|
||
</MenuItem>
|
||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.sha))}>
|
||
{t("changes.copySha")}
|
||
</MenuItem>
|
||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
|
||
{t("changes.copySubject")}
|
||
</MenuItem>
|
||
<MenuSeparator />
|
||
{historyRef ? (
|
||
<MenuItem
|
||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||
testId="lane-changes-menu-cherry-pick"
|
||
onSelect={pick(
|
||
commitCtx.close,
|
||
() => void historyAct("cherry-pick", commitCtx.menu!.item)
|
||
)}
|
||
>
|
||
{t("changes.cherryPick", { branch: git?.available ? git.branch : "HEAD" })}
|
||
</MenuItem>
|
||
) : (
|
||
<>
|
||
<MenuItem
|
||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||
testId="lane-changes-menu-revert"
|
||
onSelect={pick(
|
||
commitCtx.close,
|
||
() => void historyAct("revert", commitCtx.menu!.item)
|
||
)}
|
||
>
|
||
{t("changes.revertCommit")}
|
||
</MenuItem>
|
||
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
|
||
<MenuItem
|
||
danger
|
||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||
testId="lane-changes-menu-undo-commit"
|
||
onSelect={pick(
|
||
commitCtx.close,
|
||
() => void historyAct("undo-commit", commitCtx.menu!.item)
|
||
)}
|
||
>
|
||
{t("changes.undoCommit")}
|
||
</MenuItem>
|
||
)}
|
||
</>
|
||
)}
|
||
</ContextMenu>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
// One change block, named by its hunk's `@@` line and its index inside that
|
||
// hunk, matched against a fresh diff at the context this diff was read
|
||
// with — so the server never applies a stale patch.
|
||
const actHunk = async (
|
||
action: "stage-hunk" | "discard-hunk",
|
||
path: string,
|
||
change: LaneDiffChange
|
||
) => {
|
||
const lane = laneId;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
await api.lanes.gitAction(target, action, {
|
||
path,
|
||
hunk: change.hunk.header,
|
||
block: change.block,
|
||
context: data?.available ? data.context : undefined,
|
||
...(action === "discard-hunk" ? { confirm: true } : {}),
|
||
});
|
||
if (laneNow.current !== lane) return;
|
||
setArmedHunk(null);
|
||
refreshGit();
|
||
await load();
|
||
// Undone under the open editor: it shows the file as it is now (the
|
||
// bars only exist while nothing is unsaved, so no draft is lost).
|
||
if (action === "discard-hunk" && text?.path === path) {
|
||
const r = await api.lanes.gitFile(target, path, "work");
|
||
if (laneNow.current !== lane || !r.available) return;
|
||
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
|
||
setDraft(r.lines.join("\n"));
|
||
}
|
||
} catch (err) {
|
||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setBusy(false);
|
||
}
|
||
};
|
||
|
||
// Undo and the commit checkbox on each change block of a file with unstaged changes. The diff is
|
||
// HEAD → working tree, so a staged-only file gets none: undoing its tree
|
||
// would leave the index holding the change.
|
||
const hunkActable = (f: LaneChangeFile) =>
|
||
range === "work" && f.unstaged && !f.untracked && !f.binary;
|
||
const hunkActions = (f: LaneChangeFile) =>
|
||
hunkActable(f)
|
||
? (change: LaneDiffChange) => {
|
||
const key = `${f.path}\n${change.hunk.header}\n${change.block}`;
|
||
const armed = armedHunk === key;
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
aria-label={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
|
||
title={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
|
||
disabled={busy}
|
||
onClick={() =>
|
||
armed ? void actHunk("discard-hunk", f.path, change) : setArmedHunk(key)
|
||
}
|
||
className={`${HUNK_CHIP} ${
|
||
armed
|
||
? "border-status-danger bg-status-danger/20 text-status-danger"
|
||
: "border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger"
|
||
}`}
|
||
>
|
||
{armed ? t("changes.undoHunkShortConfirm") : t("changes.undoHunkShort")}
|
||
</button>
|
||
<TickBox
|
||
state={blockIncluded(f, change)}
|
||
label={t("changes.includeBlock")}
|
||
onChange={() => toggleBlock(f, change)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
: undefined;
|
||
|
||
// What Commit sends: whole files as `paths`, and for a partly ticked file
|
||
// its ticked blocks as `hunks` — read fresh at the default context, so the
|
||
// headers are the ones the server's own re-read will find.
|
||
const selection = async () => {
|
||
const paths = tickedPaths.filter((p) => !excluded.get(p)?.size);
|
||
const hunks: Record<string, { hunk: string; blocks: number[] }[]> = {};
|
||
for (const p of tickedPaths.filter((p) => excluded.get(p)?.size)) {
|
||
const r = await api.lanes.changes(target, {
|
||
range: "work",
|
||
path: p,
|
||
context: CONTEXT_STEPS[0],
|
||
});
|
||
const f = r.available ? r.files.find((x) => x.path === p) : undefined;
|
||
const picks = new Map<string, number[]>();
|
||
for (const [c, key] of blockKeys(f?.hunks ?? [])) {
|
||
if (excluded.get(p)?.has(key)) continue;
|
||
picks.set(c.hunk.header, [...(picks.get(c.hunk.header) ?? []), c.block]);
|
||
}
|
||
if (picks.size) hunks[p] = [...picks].map(([hunk, blocks]) => ({ hunk, blocks }));
|
||
}
|
||
return { paths, hunks: Object.keys(hunks).length ? hunks : undefined };
|
||
};
|
||
|
||
// Commit (and Push): the ticked files, `git commit --only` on the server.
|
||
const commitTicked = async (push = false) => {
|
||
// Amend rewrites the last commit: asked, like every history rewrite.
|
||
if (
|
||
amend &&
|
||
!(await confirmDialog({
|
||
title: t("changes.amendTitle"),
|
||
message: t("changes.amendConfirm", {
|
||
subject: git?.available ? git.subject : "",
|
||
}),
|
||
confirmLabel: t("changes.amend"),
|
||
}))
|
||
) {
|
||
return;
|
||
}
|
||
const lane = laneId;
|
||
let committed = false;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const { paths, hunks } = await selection();
|
||
const part = hunks ? { hunks, context: CONTEXT_STEPS[0] } : {};
|
||
if (amend) {
|
||
// No message keeps the old one (`--no-edit`); no file ticked rewords.
|
||
// `sha`: the commit the dialog named (ESTALE if HEAD moved since).
|
||
const head = git?.available ? git.head : null;
|
||
await api.lanes.gitAction(target, "amend", {
|
||
...(message.trim() ? { message: message.trim() } : {}),
|
||
...(head ? { sha: head } : {}),
|
||
paths,
|
||
...part,
|
||
});
|
||
} else {
|
||
await api.lanes.gitAction(target, "commit", { message: message.trim(), paths, ...part });
|
||
}
|
||
committed = true;
|
||
if (laneNow.current !== lane) return;
|
||
// Cleared before the push: a failed push must not invite a second commit.
|
||
setMessage("");
|
||
setAmend(false);
|
||
if (push) await api.lanes.gitAction(target, "push", {});
|
||
refreshGit();
|
||
await load();
|
||
} catch (err) {
|
||
if (laneNow.current !== lane) return;
|
||
const why = err instanceof Error ? err.message : String(err);
|
||
setError(committed ? t("changes.pushFailed", { error: why }) : why);
|
||
if (committed) {
|
||
refreshGit();
|
||
void load();
|
||
}
|
||
} finally {
|
||
if (laneNow.current === lane) setBusy(false);
|
||
}
|
||
};
|
||
|
||
// ~10 s: the server runs `claude -p` on the staged diff. Its own flag, not
|
||
// `busy`, so staging stays usable while it thinks.
|
||
const suggestMessage = async () => {
|
||
const lane = laneId;
|
||
setSuggesting(true);
|
||
try {
|
||
const { message: suggested } = await api.lanes.suggestCommitMessage(target, tickedPaths);
|
||
if (laneNow.current === lane) setMessage(suggested);
|
||
} catch (err) {
|
||
if (laneNow.current === lane) toast.error(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setSuggesting(false);
|
||
}
|
||
};
|
||
|
||
const rereadAll = () => {
|
||
refreshGit();
|
||
// The tree cache is dropped whole: after a switch any folder may differ.
|
||
setTreeChildren({});
|
||
setExpandedDirs(new Set());
|
||
setReread((n) => n + 1);
|
||
return load();
|
||
};
|
||
const [rereading, runReread] = useSpin(rereadAll);
|
||
|
||
// Files tab transfers (issue #78): a download is a plain attachment link; an
|
||
// upload only creates, into the folder it was asked for ("" = the root).
|
||
const uploadInput = useRef<HTMLInputElement>(null);
|
||
const uploadDir = useRef("");
|
||
const [, setUploading] = useState(false);
|
||
// The File Explorer (the toolbar button where Upload was); the tree is
|
||
// re-read when it closes after changing anything.
|
||
const [explorer, setExplorer] = useState<{ path: string } | null>(null);
|
||
const explorerChanged = useRef(false);
|
||
const download = (path: string) => {
|
||
const a = document.createElement("a");
|
||
a.href = api.lanes.gitDownloadUrl(target, path);
|
||
a.download = path.split("/").pop() || path;
|
||
a.click();
|
||
};
|
||
const pickUpload = (dir: string) => {
|
||
uploadDir.current = dir;
|
||
uploadInput.current?.click();
|
||
};
|
||
const upload = async (files: File[]) => {
|
||
if (!files.length) return;
|
||
const lane = laneId;
|
||
const dir = uploadDir.current;
|
||
setUploading(true);
|
||
let saved = 0;
|
||
try {
|
||
for (const f of files) {
|
||
try {
|
||
await api.lanes.gitUpload(target, dir, f);
|
||
saved++;
|
||
} catch (err) {
|
||
toast.error(
|
||
t("changes.uploadFailed", {
|
||
name: f.name,
|
||
message: err instanceof Error ? err.message : String(err),
|
||
})
|
||
);
|
||
}
|
||
}
|
||
} finally {
|
||
if (laneNow.current === lane) {
|
||
setUploading(false);
|
||
if (saved) {
|
||
toast.success(t("changes.uploaded", { count: saved, dir: dir || "/" }));
|
||
// Re-read the level it landed in, and open it.
|
||
void api.lanes
|
||
.gitTree(target, dir)
|
||
.then(forLane(lane, (r) => setTreeChildren((prev) => ({ ...prev, [dir]: r.entries }))))
|
||
.catch(() => undefined);
|
||
if (dir) setExpandedDirs((prev) => new Set(prev).add(dir));
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const toggleCollapsed = (path: string) =>
|
||
setCollapsed((prev) => {
|
||
const next = new Set(prev);
|
||
if (!next.delete(path)) next.add(path);
|
||
return next;
|
||
});
|
||
|
||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||
dirtyRef.current = dirty;
|
||
useUnsavedGuard(dirty);
|
||
const dirtyChangeRef = useRef(onDirtyChange);
|
||
dirtyChangeRef.current = onDirtyChange;
|
||
useEffect(() => dirtyChangeRef.current?.(dirty), [dirty]);
|
||
// Unmounted with an edit open (the panel closed or moved on, after the
|
||
// Workspace asked): nothing is dirty any more.
|
||
useEffect(() => () => dirtyChangeRef.current?.(false), []);
|
||
|
||
const files = data?.available ? data.files : [];
|
||
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
|
||
// What Commit sends: the ticked files still listed, in list order. Never a
|
||
// conflicted one (they have no checkbox).
|
||
const tickedPaths =
|
||
range === "work"
|
||
? files.filter((f) => ticked.has(f.path) && !f.conflicted).map((f) => f.path)
|
||
: [];
|
||
const canCommit =
|
||
!busy &&
|
||
!suggesting &&
|
||
(amend
|
||
? tickedPaths.length > 0 || !!message.trim()
|
||
: tickedPaths.length > 0 && !!message.trim());
|
||
// A newly listed file takes its default — a tracked change ticked, an
|
||
// unversioned or never-commit one not — and a file gone from the list
|
||
// leaves the set. Only the full overview list may say "gone".
|
||
useEffect(() => {
|
||
if (!data?.available || data.range !== "work" || mode !== "overview") return;
|
||
const seen = seenPaths.current;
|
||
setTicked((prev) => {
|
||
const next = new Set<string>();
|
||
for (const f of data.files) {
|
||
const on = seen?.has(f.path) ? prev.has(f.path) : !f.untracked && !f.conflicted;
|
||
if (on) next.add(f.path);
|
||
}
|
||
return next;
|
||
});
|
||
seenPaths.current = new Set(data.files.map((f) => f.path));
|
||
setExcluded((prev) => new Map([...prev].filter(([p]) => seenPaths.current!.has(p))));
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [data]);
|
||
useEffect(() => {
|
||
if (data?.available && data.range === range && (mode === "overview" || mode === "review")) {
|
||
setListed(data.files.map((f) => f.path));
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [data]);
|
||
const dropExcluded = (paths: string[]) =>
|
||
setExcluded((prev) => {
|
||
if (!paths.some((p) => prev.has(p))) return prev;
|
||
const next = new Map(prev);
|
||
for (const p of paths) next.delete(p);
|
||
return next;
|
||
});
|
||
// A file's commit box: all of it, none of it, or some of its blocks.
|
||
const fileTick = (path: string): boolean | "some" =>
|
||
!ticked.has(path) ? false : excluded.get(path)?.size ? "some" : true;
|
||
const blockIncluded = (f: LaneChangeFile, change: LaneDiffChange) => {
|
||
const key = blockKeys(f.hunks ?? []).get(change);
|
||
return ticked.has(f.path) && !(key && excluded.get(f.path)?.has(key));
|
||
};
|
||
// One block's checkbox. In an unticked file it takes just that block, as
|
||
// JetBrains does; leaving every block out unticks the file.
|
||
const toggleBlock = (f: LaneChangeFile, change: LaneDiffChange) => {
|
||
const keys = blockKeys(f.hunks ?? []);
|
||
const key = keys.get(change);
|
||
if (!key) return;
|
||
const all = [...keys.values()];
|
||
const out = ticked.has(f.path) ? new Set(excluded.get(f.path)) : new Set(all);
|
||
if (!out.delete(key)) out.add(key);
|
||
const none = all.every((k) => out.has(k));
|
||
tick(f.path, !none);
|
||
setExcluded((prev) => {
|
||
const next = new Map(prev);
|
||
if (none || out.size === 0) next.delete(f.path);
|
||
else next.set(f.path, out);
|
||
return next;
|
||
});
|
||
};
|
||
const tick = (path: string, on: boolean) => {
|
||
dropExcluded([path]);
|
||
setTicked((prev) => {
|
||
const next = new Set(prev);
|
||
if (on) next.add(path);
|
||
else next.delete(path);
|
||
return next;
|
||
});
|
||
};
|
||
// A folder's or a group's box: every file in it, whole, on or off.
|
||
const tickAll = (rows: LaneChangeFile[], on: boolean) => {
|
||
dropExcluded(rows.map((f) => f.path));
|
||
setTicked((prev) => {
|
||
const next = new Set(prev);
|
||
for (const f of rows) {
|
||
if (on) next.add(f.path);
|
||
else next.delete(f.path);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
// Commit File…: that file alone, and the message box to type into.
|
||
const commitFile = (path: string) => {
|
||
setTicked(new Set([path]));
|
||
setExcluded(new Map());
|
||
messageRef.current?.focus();
|
||
};
|
||
// Copy as Patch: the file's diff read for its path, as `git apply` takes it.
|
||
const copyPatch = async (p: string) => {
|
||
try {
|
||
const r = await api.lanes.changes(target, {
|
||
range,
|
||
...(range === "commit" && sha ? { sha } : {}),
|
||
path: p,
|
||
context: 3,
|
||
});
|
||
const f = r.available ? r.files.find((x) => x.path === p) : undefined;
|
||
if (!f?.hunks?.length) {
|
||
toast.error(t("changes.noPatch"));
|
||
return;
|
||
}
|
||
await copyText(toPatch(f));
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
}
|
||
};
|
||
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
|
||
// "vs base" needs a base, and means nothing on the base branch itself. An
|
||
// older server reports no `base` at all: keep the toggle as it was.
|
||
const base = git?.available ? git.base : undefined;
|
||
const showBranchRange =
|
||
base === undefined || (base !== null && !(git?.available && git.branch === base));
|
||
|
||
// The armed path left the list (committed, reverted, lane switched): disarm,
|
||
// so a later reappearance starts from the first click again.
|
||
useEffect(() => {
|
||
if (!showBranchRange && range === "branch") setRange("work");
|
||
}, [showBranchRange, range]);
|
||
useEffect(() => {
|
||
if (confirmDiscard && data && !files.some((f) => f.path === confirmDiscard)) {
|
||
setConfirmDiscard(null);
|
||
}
|
||
}, [data, files, confirmDiscard]);
|
||
|
||
// 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);
|
||
};
|
||
// Jump to Source: the whole file, changed or not.
|
||
const jumpToSource = async (p: string) => {
|
||
if (!(await confirmDrop())) return;
|
||
showNow(p);
|
||
editWanted.current = editableFile(range, p);
|
||
setWhole(true);
|
||
};
|
||
|
||
// The Files mode's body. At full width it lives in the sidebar instead, so
|
||
// the Files button is gone there and the main area never shows it twice.
|
||
const browsePane = (
|
||
<div data-testid="lane-changes-browse">
|
||
{/* Pinned: the finder is what you reach for after scrolling a
|
||
long tree, not only from its top. */}
|
||
<div className="sticky top-0 z-10 flex items-center gap-1.5 bg-surface-1 pb-2">
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder={t("changes.quickOpen")}
|
||
aria-label={t("changes.quickOpen")}
|
||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||
/>
|
||
<IconButton label={t("changes.openExplorer")} onClick={() => setExplorer({ path: "" })}>
|
||
<FolderOpen className="h-3.5 w-3.5" />
|
||
</IconButton>
|
||
<FileExplorer
|
||
open={!!explorer}
|
||
laneId={laneId}
|
||
session={session}
|
||
source={source}
|
||
initialPath={explorer?.path ?? ""}
|
||
onChanged={() => (explorerChanged.current = true)}
|
||
onClose={() => {
|
||
setExplorer(null);
|
||
if (explorerChanged.current) {
|
||
explorerChanged.current = false;
|
||
void rereadAll();
|
||
}
|
||
}}
|
||
/>
|
||
<input
|
||
ref={uploadInput}
|
||
type="file"
|
||
multiple
|
||
hidden
|
||
data-testid="lane-changes-upload-input"
|
||
onChange={(e) => {
|
||
const files = [...(e.target.files ?? [])];
|
||
e.target.value = "";
|
||
void upload(files);
|
||
}}
|
||
/>
|
||
{!hits && (
|
||
<IconButton
|
||
label={
|
||
expandedDirs.size > 0
|
||
? t("changes.collapseAllFolders")
|
||
: t("changes.expandAllFolders")
|
||
}
|
||
disabled={treeBusy}
|
||
onClick={() =>
|
||
expandedDirs.size > 0 ? setExpandedDirs(new Set()) : void expandAllDirs()
|
||
}
|
||
>
|
||
{expandedDirs.size > 0 ? (
|
||
<ChevronsDownUp className="h-3.5 w-3.5" />
|
||
) : (
|
||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||
)}
|
||
</IconButton>
|
||
)}
|
||
</div>
|
||
{hits ? (
|
||
<ul className="space-y-0.5">
|
||
{hits.map((p) => (
|
||
<li key={p}>
|
||
<button
|
||
type="button"
|
||
onClick={() => void openWhole(p)}
|
||
draggable
|
||
onDragStart={(ev) => dragProjectPath(ev, p)}
|
||
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
|
||
>
|
||
{p}
|
||
</button>
|
||
</li>
|
||
))}
|
||
{hits.length === 0 && (
|
||
<li className="text-[11px] text-fg-muted">{t("changes.noMatches")}</li>
|
||
)}
|
||
</ul>
|
||
) : (
|
||
<ul className="space-y-0.5">
|
||
<TreeNodes
|
||
path=""
|
||
depth={0}
|
||
children={treeChildren}
|
||
expanded={expandedDirs}
|
||
onToggleDir={toggleDir}
|
||
onOpenFile={(p) => void openWhole(p)}
|
||
onMenu={treeCtx.open}
|
||
active={full && mode === "file" ? openFile : null}
|
||
/>
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div
|
||
data-testid="lane-changes"
|
||
className={`flex min-h-0 flex-1 ${full ? "gap-3" : "flex-col"}`}
|
||
onKeyDown={(e) => {
|
||
if (e.key !== "F7" || (mode !== "file" && mode !== "review")) return;
|
||
e.preventDefault();
|
||
stepDiff(e.shiftKey ? -1 : 1);
|
||
}}
|
||
>
|
||
{menus}
|
||
{/* Full width has room for an explorer: the tree stays beside the file
|
||
it opened, instead of being a mode you leave to read one. */}
|
||
{full && (
|
||
<aside
|
||
data-testid="lane-changes-sidebar"
|
||
className="flex w-64 shrink-0 flex-col overflow-y-auto border-r border-border-light pr-2"
|
||
>
|
||
{browsePane}
|
||
</aside>
|
||
)}
|
||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||
<LaneGitHeader
|
||
laneId={laneId}
|
||
session={session}
|
||
source={source}
|
||
git={git}
|
||
onChanged={rereadAll}
|
||
onNewLane={onNewLane}
|
||
/>
|
||
{/* ONE wrapping row: where you are (mode) then how you are looking at it
|
||
(range, layout, context, whole file), refresh last. Each group is
|
||
its own inline box, so a narrow panel wraps BETWEEN groups — never
|
||
through one, which is what made the old single row unreadable. */}
|
||
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
||
<div className="contents">
|
||
{mode === "file" ? (
|
||
<button
|
||
type="button"
|
||
onClick={goBack}
|
||
className="flex items-center gap-0.5 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
|
||
>
|
||
<ChevronLeft className="h-3 w-3" />
|
||
{t("changes.back")}
|
||
</button>
|
||
) : (
|
||
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
|
||
{/* Files is the whole repo's tree; at full width it is the
|
||
sidebar already, so no button. */}
|
||
{(full
|
||
? (["overview", "review", "history"] as const)
|
||
: (["overview", "browse", "review", "history"] as const)
|
||
).map((m) => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
aria-pressed={mode === m}
|
||
onClick={() => {
|
||
// The History button is the branch's; Show History, a file's.
|
||
if (m === "history" && historyFile) {
|
||
setHistoryFile(null);
|
||
setCommits(null);
|
||
}
|
||
setMode(m);
|
||
}}
|
||
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||
mode === m
|
||
? "bg-accent-solid text-white"
|
||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||
}`}
|
||
>
|
||
{t(`changes.mode.${m}`)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={runReread}
|
||
aria-label={t("changes.refresh")}
|
||
title={t("changes.refresh")}
|
||
className="order-last ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
|
||
>
|
||
<RefreshCw className={`h-3.5 w-3.5 ${loading || rereading ? "animate-spin" : ""}`} />
|
||
</button>
|
||
</div>
|
||
|
||
{mode !== "history" && mode !== "browse" && (
|
||
<div className="contents">
|
||
{/* With nothing to compare against, "Uncommitted" alone would be a
|
||
toggle with one side: none is drawn, unless a commit is open —
|
||
then Uncommitted is the way back. */}
|
||
{(showBranchRange || (range === "commit" && sha)) && (
|
||
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||
{(showBranchRange ? (["work", "branch"] as const) : (["work"] as const)).map(
|
||
(r) => (
|
||
<button
|
||
key={r}
|
||
type="button"
|
||
aria-pressed={range === r}
|
||
onClick={() => {
|
||
setRange(r);
|
||
setSha(null);
|
||
}}
|
||
title={t(`changes.range.${r}Hint`, {
|
||
base: base || t("changes.range.baseBranch"),
|
||
})}
|
||
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
|
||
range === r
|
||
? "bg-surface-4 text-fg-primary"
|
||
: "text-fg-secondary hover:text-fg-primary"
|
||
}`}
|
||
>
|
||
{t(`changes.range.${r}`, { base: base || t("changes.range.baseBranch") })}
|
||
</button>
|
||
)
|
||
)}
|
||
{range === "commit" && sha && (
|
||
<span
|
||
data-testid="lane-changes-commit-chip"
|
||
className="rounded bg-surface-4 px-2 py-0.5 font-mono text-[11px] text-fg-primary"
|
||
>
|
||
{sha.slice(0, 7)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{(mode === "file" || mode === "review") && (
|
||
<div className="flex items-center gap-0.5 rounded border border-border bg-surface-2 p-0.5">
|
||
{mode === "review" && (
|
||
<IconButton
|
||
label={allCollapsed ? t("changes.expandAll") : t("changes.collapseAll")}
|
||
disabled={files.length === 0}
|
||
onClick={() =>
|
||
setCollapsed(allCollapsed ? new Set() : new Set(files.map((f) => f.path)))
|
||
}
|
||
>
|
||
{allCollapsed ? (
|
||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||
) : (
|
||
<ChevronsDownUp className="h-3.5 w-3.5" />
|
||
)}
|
||
</IconButton>
|
||
)}
|
||
<IconButton
|
||
label={t(`changes.layout.${layout === "unified" ? "split" : "unified"}`)}
|
||
onClick={() => setLayout(layout === "unified" ? "split" : "unified")}
|
||
>
|
||
{layout === "unified" ? (
|
||
<Columns2 className="h-3.5 w-3.5" />
|
||
) : (
|
||
<Rows3 className="h-3.5 w-3.5" />
|
||
)}
|
||
</IconButton>
|
||
{mode === "file" && (
|
||
<>
|
||
{/* Context walks BOTH ways: an expanded diff that cannot
|
||
be collapsed again leaves the reader re-opening the
|
||
file to get the tight view back. */}
|
||
<IconButton
|
||
label={t("changes.collapseContext")}
|
||
disabled={whole || draft !== null || context <= CONTEXT_STEPS[0]}
|
||
onClick={() =>
|
||
setContext(
|
||
[...CONTEXT_STEPS].reverse().find((step) => step < context) ??
|
||
CONTEXT_STEPS[0]
|
||
)
|
||
}
|
||
>
|
||
<FoldVertical className="h-3.5 w-3.5" />
|
||
</IconButton>
|
||
<IconButton
|
||
label={t("changes.expandContext")}
|
||
disabled={whole || draft !== null || context >= LAST_CONTEXT}
|
||
onClick={() =>
|
||
setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)
|
||
}
|
||
>
|
||
<UnfoldVertical className="h-3.5 w-3.5" />
|
||
</IconButton>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{(mode === "file" || mode === "review") && (
|
||
<div
|
||
data-testid="lane-changes-nav"
|
||
className="flex items-center gap-0.5 rounded border border-border bg-surface-2 p-0.5"
|
||
>
|
||
<IconButton label={t("changes.prevDiff")} onClick={() => stepDiff(-1)}>
|
||
<ChevronUp className="h-3.5 w-3.5" />
|
||
</IconButton>
|
||
<IconButton label={t("changes.nextDiff")} onClick={() => stepDiff(1)}>
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
</IconButton>
|
||
{mode === "file" && (
|
||
<>
|
||
<IconButton
|
||
label={t("changes.prevFile")}
|
||
disabled={fileAt <= 0}
|
||
onClick={() => stepFile(-1)}
|
||
>
|
||
<ChevronLeft className="h-3.5 w-3.5" />
|
||
</IconButton>
|
||
<IconButton
|
||
label={t("changes.nextFile")}
|
||
disabled={fileAt < 0 || fileAt >= listed.length - 1}
|
||
onClick={() => stepFile(1)}
|
||
>
|
||
<ChevronRight className="h-3.5 w-3.5" />
|
||
</IconButton>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{mode === "file" && (
|
||
<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>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<p role="alert" className="mb-2 whitespace-pre-wrap text-[11px] text-status-danger">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
{git?.available && git.merging && (
|
||
<div
|
||
data-testid="lane-merge-banner"
|
||
className="mb-2 flex flex-wrap items-center gap-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
|
||
>
|
||
<span className="min-w-0 flex-1">
|
||
{git.conflicts
|
||
? t("changes.mergeBanner", { count: git.conflicts, op: git.operation ?? "merge" })
|
||
: t("changes.mergeReady", { op: git.operation ?? "merge" })}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
disabled={busy || !!git.conflicts}
|
||
onClick={() => void mergeAct("merge-commit")}
|
||
className="shrink-0 rounded border border-accent/40 bg-accent/15 px-1.5 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{t("changes.mergeCommit", { op: git.operation ?? "merge" })}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
// Two clicks, like discard: aborting drops every resolution made.
|
||
onClick={() =>
|
||
armedAbort ? void mergeAct("merge-abort", { confirm: true }) : setArmedAbort(true)
|
||
}
|
||
className="shrink-0 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:opacity-40"
|
||
>
|
||
{armedAbort
|
||
? t("changes.mergeAbortConfirm", { op: git.operation ?? "merge" })
|
||
: t("changes.mergeAbort", { op: git.operation ?? "merge" })}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
|
||
bar sits at the bottom of the visible box instead of at the bottom of
|
||
a 2000-line container nobody has scrolled to yet. Every other mode is
|
||
a list, and a list scrolls here. */}
|
||
<div
|
||
ref={scroller}
|
||
data-testid="lane-changes-body"
|
||
className={`flex min-h-0 flex-1 flex-col ${
|
||
mode === "file" ? "overflow-hidden" : "overflow-y-auto"
|
||
}`}
|
||
>
|
||
{mode === "history" ? (
|
||
<ul data-testid="lane-changes-history">
|
||
{historyFile && (
|
||
<li
|
||
data-testid="lane-changes-history-file"
|
||
className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary"
|
||
>
|
||
<span className="min-w-0 flex-1 truncate">
|
||
{t("changes.historyOfFile", { path: historyFile })}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setHistoryFile(null);
|
||
setCommits(null);
|
||
}}
|
||
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||
>
|
||
{t("changes.historyWholeBranch")}
|
||
</button>
|
||
</li>
|
||
)}
|
||
{historyBranches.length > 1 && (
|
||
<li className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary">
|
||
<span className="shrink-0">{t("changes.historyOf")}</span>
|
||
<select
|
||
data-testid="lane-changes-history-ref"
|
||
value={historyRef ?? ""}
|
||
onChange={(e) => {
|
||
setHistoryRef(e.target.value || null);
|
||
setCommits(null);
|
||
}}
|
||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
|
||
>
|
||
<option value="">{git?.available ? git.branch : "HEAD"}</option>
|
||
{historyBranches
|
||
.filter((b) => !(git?.available && b === git.branch))
|
||
.map((b) => (
|
||
<option key={b} value={b}>
|
||
{b}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</li>
|
||
)}
|
||
{(() => {
|
||
const list = commits ?? [];
|
||
const graph = commitGraph(list);
|
||
return list.map((c, i) => (
|
||
<li key={c.sha}>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
historyFile ? openAtCommit(historyFile, c.sha) : 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>
|
||
</button>
|
||
</li>
|
||
));
|
||
})()}
|
||
{commits === null && (
|
||
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
|
||
)}
|
||
{commits && commits.length === 0 && (
|
||
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
|
||
)}
|
||
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
|
||
<li>
|
||
<button
|
||
type="button"
|
||
data-testid="lane-changes-history-more"
|
||
disabled={historyLoading}
|
||
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
|
||
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
|
||
>
|
||
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
|
||
</button>
|
||
</li>
|
||
)}
|
||
</ul>
|
||
) : mode === "browse" && !full ? (
|
||
browsePane
|
||
) : !data ? (
|
||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||
) : !data.available ? (
|
||
<p className="text-[11px] text-fg-muted">
|
||
{t(source ? "changes.unavailableFolder" : "changes.unavailable")}
|
||
</p>
|
||
) : files.length === 0 && mode !== "file" ? (
|
||
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
|
||
) : mode === "overview" ? (
|
||
<>
|
||
{/* Commit and Stage all sit ABOVE the list and stay pinned while it
|
||
scrolls: on a 60-file change-set the box at the bottom was a
|
||
scroll away from every file you had just staged. */}
|
||
{range === "work" && !opOpen && files.some((f) => !f.conflicted) && (
|
||
<div
|
||
data-testid="lane-changes-actions"
|
||
className="sticky top-0 z-10 mb-1.5 flex flex-wrap items-center gap-1.5 border-b border-border-light bg-surface-1 pb-2"
|
||
>
|
||
{/* One row when there is room; in a narrow panel the buttons
|
||
wrap below rather than squeeze the message to a few
|
||
characters (its min width is what triggers the wrap). */}
|
||
<div
|
||
data-testid="lane-changes-commit"
|
||
className="flex min-w-[14rem] flex-1 items-center gap-1.5"
|
||
>
|
||
<input
|
||
ref={messageRef}
|
||
value={message}
|
||
onChange={(e) => setMessage(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (isSubmitEnter(e) && canCommit) void commitTicked();
|
||
}}
|
||
placeholder={t(
|
||
amend ? "changes.amendPlaceholder" : "changes.commitPlaceholder"
|
||
)}
|
||
aria-label={t(
|
||
amend ? "changes.amendPlaceholder" : "changes.commitPlaceholder"
|
||
)}
|
||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||
/>
|
||
<button
|
||
type="button"
|
||
data-testid="lane-changes-suggest-message"
|
||
disabled={suggesting || tickedPaths.length === 0}
|
||
onClick={() => void suggestMessage()}
|
||
title={t("changes.suggestMessage")}
|
||
aria-label={t("changes.suggestMessage")}
|
||
className="shrink-0 flex items-center rounded border border-border-light px-1 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{suggesting ? (
|
||
<RefreshCw className="h-3 w-3 animate-spin" aria-hidden />
|
||
) : (
|
||
<Sparkles className="h-3 w-3" aria-hidden />
|
||
)}
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
<label
|
||
title={headPushed ? t("changes.amendPushed") : t("changes.amendHint")}
|
||
className={`flex shrink-0 items-center gap-1 text-[11px] text-fg-secondary ${
|
||
headPushed ? "cursor-not-allowed opacity-50" : "cursor-pointer"
|
||
}`}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
data-testid="lane-changes-amend"
|
||
checked={amend}
|
||
disabled={headPushed}
|
||
onChange={(e) => setAmend(e.target.checked)}
|
||
/>
|
||
{t("changes.amend")}
|
||
</label>
|
||
<button
|
||
type="button"
|
||
disabled={!canCommit}
|
||
onClick={() => void commitTicked()}
|
||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{amend
|
||
? t("changes.amend")
|
||
: t("changes.commitTicked", { count: tickedPaths.length })}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!canCommit}
|
||
onClick={() => void commitTicked(true)}
|
||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{t(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* The work range splits like JetBrains' Commit window: tracked
|
||
changes (staged or not — the checkbox picks, not the index),
|
||
then the files git does not know yet. */}
|
||
<div data-testid="lane-changes-overview">
|
||
{(range === "work"
|
||
? [
|
||
{ key: "conflicts", rows: files.filter((f) => f.conflicted) },
|
||
{ key: "changes", rows: files.filter((f) => !f.untracked && !f.conflicted) },
|
||
{
|
||
key: "unversioned",
|
||
rows: files.filter((f) => f.untracked && !f.conflicted),
|
||
},
|
||
]
|
||
: [{ key: "all", rows: files }]
|
||
).map(
|
||
(g) =>
|
||
g.rows.length > 0 && (
|
||
<div
|
||
key={g.key}
|
||
className="mb-1.5"
|
||
data-testid={g.key === "conflicts" ? "lane-changes-conflicts" : undefined}
|
||
>
|
||
{g.key !== "all" && (
|
||
<div className="flex items-center gap-1 px-1">
|
||
{g.key !== "conflicts" && (
|
||
<TickBox
|
||
state={tickState(g.rows, fileTick)}
|
||
label={t("changes.tickFolder", {
|
||
path: t(`changes.${g.key}Group`),
|
||
})}
|
||
onChange={(on) => tickAll(g.rows, on)}
|
||
/>
|
||
)}
|
||
<button
|
||
type="button"
|
||
aria-expanded={!treeCollapsed.has(`${g.key}:`)}
|
||
onClick={() => toggleTreeDir(`${g.key}:`)}
|
||
className="flex min-w-0 flex-1 items-center gap-1.5 py-0.5 text-left text-[10px] font-semibold uppercase tracking-wide text-fg-secondary hover:text-fg-primary"
|
||
>
|
||
{treeCollapsed.has(`${g.key}:`) ? (
|
||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||
) : (
|
||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||
)}
|
||
{t(`changes.${g.key}Group`)}
|
||
<span className="rounded-full bg-surface-4 px-1.5 font-mono text-fg-primary">
|
||
{g.rows.length}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!treeCollapsed.has(`${g.key}:`) && (
|
||
<ul data-group={g.key} className="space-y-0.5">
|
||
{/* Folders like JetBrains' commit view: a long
|
||
change-set reads by area, not as one flat list. */}
|
||
<ChangeTreeNodes
|
||
dir={changeTree(g.rows)}
|
||
depth={g.key === "all" ? 0 : 1}
|
||
group={g.key}
|
||
collapsed={treeCollapsed}
|
||
onToggle={toggleTreeDir}
|
||
renderFile={(f, name, depth) => (
|
||
<ChangeRow
|
||
key={f.path}
|
||
f={f}
|
||
name={name}
|
||
depth={depth}
|
||
range={range}
|
||
busy={busy}
|
||
armed={confirmDiscard === f.path}
|
||
onOpen={show}
|
||
onAct={(...a) => void act(...a)}
|
||
onArm={setConfirmDiscard}
|
||
onResolve={resolve}
|
||
onMenu={fileCtx.open}
|
||
ticked={fileTick(f.path)}
|
||
onTick={
|
||
g.key === "changes" || g.key === "unversioned"
|
||
? tick
|
||
: undefined
|
||
}
|
||
/>
|
||
)}
|
||
folderTick={
|
||
g.key === "changes" || g.key === "unversioned"
|
||
? (rows, path) => (
|
||
<TickBox
|
||
state={tickState(rows, fileTick)}
|
||
label={t("changes.tickFolder", { path })}
|
||
onChange={(on) => tickAll(rows, on)}
|
||
/>
|
||
)
|
||
: undefined
|
||
}
|
||
/>
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
</>
|
||
) : mode === "file" && range === "work" && current?.conflicted && !rawEdit && openFile ? (
|
||
<LaneConflictView
|
||
laneId={laneId}
|
||
session={session}
|
||
source={source}
|
||
path={openFile}
|
||
onResolved={() => {
|
||
refreshGit();
|
||
void load();
|
||
goBack();
|
||
}}
|
||
onEdit={() => {
|
||
setRawEdit(true);
|
||
editWanted.current = true;
|
||
setWhole(true);
|
||
}}
|
||
/>
|
||
) : mode === "file" ? (
|
||
current || (whole && text) || openImage ? (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<div className="mb-1 flex items-baseline gap-1.5">
|
||
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
|
||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary">
|
||
{openFile}
|
||
</span>
|
||
{current && (
|
||
<Delta
|
||
added={current.added}
|
||
deleted={current.deleted}
|
||
binary={current.binary}
|
||
/>
|
||
)}
|
||
{range === "work" && !openImage && editable && (
|
||
<button
|
||
type="button"
|
||
aria-pressed={annotate}
|
||
onClick={() => void toggleAnnotate()}
|
||
className={`flex shrink-0 items-center gap-1 self-center rounded border px-1.5 py-0.5 text-[11px] hover:bg-surface-2 ${
|
||
annotate
|
||
? "border-accent text-accent"
|
||
: "border-border-light text-fg-secondary hover:text-fg-primary"
|
||
}`}
|
||
>
|
||
{t("changes.annotate")}
|
||
</button>
|
||
)}
|
||
{whole && text && !dirty && isMarkdown(openFile) && (
|
||
<button
|
||
type="button"
|
||
data-testid="lane-file-preview"
|
||
aria-pressed={mdPreview}
|
||
onClick={() => setMdPreview((v) => !v)}
|
||
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||
>
|
||
{mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||
{mdPreview ? t("changes.raw") : t("changes.preview")}
|
||
</button>
|
||
)}
|
||
{range !== "commit" && openFile && (
|
||
<button
|
||
type="button"
|
||
data-testid="lane-file-download"
|
||
onClick={() => download(openFile)}
|
||
title={t("changes.download")}
|
||
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||
>
|
||
<Download className="h-3 w-3" />
|
||
{t("changes.download")}
|
||
</button>
|
||
)}
|
||
{/* Only the working file is editable: a commit is history. */}
|
||
{/* 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 ? (
|
||
<span
|
||
data-testid="lane-file-noedit"
|
||
className="shrink-0 self-center text-[10px] text-status-warning"
|
||
>
|
||
{t(noEditReason(text))}
|
||
</span>
|
||
) : draft === null && whole ? null : draft === null ? (
|
||
<button
|
||
type="button"
|
||
data-testid="lane-file-edit"
|
||
disabled={busy}
|
||
onClick={() => {
|
||
// The file is not loaded yet: the editor opens as
|
||
// soon as the whole-file read lands.
|
||
editWanted.current = true;
|
||
setWhole(true);
|
||
}}
|
||
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
<Pencil className="h-3 w-3" />
|
||
{t("changes.edit")}
|
||
</button>
|
||
) : (
|
||
<>
|
||
<button
|
||
type="button"
|
||
data-testid="lane-file-save"
|
||
disabled={busy || !dirty}
|
||
onClick={() => void saveDraft()}
|
||
className="shrink-0 self-center rounded border border-accent bg-accent/15 px-1.5 py-0.5 text-[11px] text-accent hover:bg-accent/25 disabled:opacity-40"
|
||
>
|
||
{t("changes.save")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
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")}
|
||
</button>
|
||
</>
|
||
))}
|
||
</div>
|
||
{openImage ? (
|
||
<ImageView
|
||
path={openImage}
|
||
src={api.lanes.gitRawUrl(
|
||
target,
|
||
openImage,
|
||
range === "commit" && sha ? sha : "work"
|
||
)}
|
||
/>
|
||
) : !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}
|
||
// Cursor's Add to Chat: `@path#La-b`, Claude Code's
|
||
// own line-range mention, pasted like a comment. The
|
||
// leading space keeps it a mention after a typed word.
|
||
onAddToChat={(a, b) => {
|
||
const at = a === b ? `L${a}` : `L${a}-${b}`;
|
||
if (!comment(` @${openFile}#${at} `))
|
||
toast.error(t("changes.commentNoPane"));
|
||
}}
|
||
onSave={() => {
|
||
if (!busy) void saveDraft();
|
||
}}
|
||
// The same Undo / Keep as the diff, on the file on
|
||
// disk — so only while the editor holds no unsaved text.
|
||
onBlock={
|
||
current && hunkActable(current) && !dirty && !busy
|
||
? (action: BlockAction, b: EditorBlock) => {
|
||
const change = gitChangeFor(current, b);
|
||
if (!change) return setError(t("changes.blockNotInDiff"));
|
||
if (action === "include") toggleBlock(current, change);
|
||
else void actHunk("discard-hunk", current.path, change);
|
||
}
|
||
: undefined
|
||
}
|
||
blockIncluded={(b: EditorBlock) => {
|
||
const change = current && gitChangeFor(current, b);
|
||
return change ? blockIncluded(current, change) : false;
|
||
}}
|
||
blame={annotate && blame?.path === openFile ? blame : undefined}
|
||
onBlameClick={viewCommit}
|
||
navRef={editorNav}
|
||
/>
|
||
</Suspense>
|
||
)
|
||
) : whole ? (
|
||
text && mdPreview ? (
|
||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
||
<MarkdownPreview text={text.lines.join("\n")} />
|
||
</div>
|
||
) : text ? (
|
||
<LaneFileView
|
||
path={text.path}
|
||
lines={text.lines}
|
||
truncated={text.truncated}
|
||
fill
|
||
/>
|
||
) : (
|
||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||
)
|
||
) : current ? (
|
||
<LaneDiffView
|
||
file={current}
|
||
layout={layout}
|
||
fill
|
||
hunkActions={whole ? undefined : hunkActions(current)}
|
||
onComment={comment}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<p className="text-[11px] text-fg-muted">{t("changes.fileGone")}</p>
|
||
)
|
||
) : (
|
||
<div data-testid="lane-changes-review" className="space-y-3">
|
||
{files.map((f) => {
|
||
const shut = collapsed.has(f.path);
|
||
return (
|
||
<div
|
||
key={f.path}
|
||
data-review-file=""
|
||
className="rounded border border-border-light"
|
||
>
|
||
{/* The whole header folds the file; only the PATH opens it.
|
||
A header that opened the file on any click made folding a
|
||
target-practice exercise against one chevron. */}
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => toggleCollapsed(f.path)}
|
||
onContextMenu={(e) => fileCtx.open(e, f)}
|
||
onKeyDown={(e) => {
|
||
// Only the header's own keys: Enter on the path button
|
||
// inside bubbles here and must open, not fold.
|
||
if (e.target !== e.currentTarget) return;
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
toggleCollapsed(f.path);
|
||
}
|
||
}}
|
||
aria-expanded={!shut}
|
||
aria-label={
|
||
shut
|
||
? t("changes.expandFile", { path: f.path })
|
||
: t("changes.collapseFile", { path: f.path })
|
||
}
|
||
data-sticky-top=""
|
||
className={`sticky top-0 z-10 flex cursor-pointer items-baseline gap-1.5 rounded-t bg-surface-2 px-2 py-1 hover:bg-surface-3 ${
|
||
shut ? "" : "border-b border-border-light"
|
||
}`}
|
||
>
|
||
<ChevronRight
|
||
className={`h-3 w-3 shrink-0 self-center text-fg-muted transition-transform ${
|
||
shut ? "" : "rotate-90"
|
||
}`}
|
||
/>
|
||
<span
|
||
className={`shrink-0 font-mono text-[11px] font-semibold ${statusClass(f.status, f.untracked)}`}
|
||
>
|
||
{f.untracked ? "A" : f.status.trim()[0] || "M"}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
show(f.path);
|
||
}}
|
||
className="min-w-0 flex-1 truncate text-left font-mono text-[11px] text-fg-primary hover:underline"
|
||
title={t("changes.openFile", { path: f.path })}
|
||
>
|
||
{f.path}
|
||
</button>
|
||
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
|
||
{range === "work" && (
|
||
// The header folds on click; its buttons must not.
|
||
<span
|
||
className="flex items-center gap-0.5 self-center"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{!f.conflicted && (
|
||
<TickBox
|
||
state={fileTick(f.path)}
|
||
label={t("changes.tickFile", { path: f.path })}
|
||
onChange={(on) => tick(f.path, on)}
|
||
/>
|
||
)}
|
||
<FileActions
|
||
f={f}
|
||
busy={busy}
|
||
armed={confirmDiscard === f.path}
|
||
onAct={(...a) => void act(...a)}
|
||
onArm={setConfirmDiscard}
|
||
vcs={!f.conflicted}
|
||
/>
|
||
</span>
|
||
)}
|
||
</div>
|
||
{!shut && (
|
||
<LaneDiffView
|
||
file={f}
|
||
layout={layout}
|
||
hunkActions={hunkActions(f)}
|
||
onComment={comment}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{data?.available && data.truncated && mode !== "history" && mode !== "browse" && (
|
||
<p className="mt-2 text-[10px] text-status-warning">{t("changes.truncated")}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default LaneChangesPanel;
|