feat: per-change-block hunk actions (#50), codex lanes without a pipeline, macOS LaunchAgent
- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js) cuts git's fresh hunk down to it, folding the other blocks into the side being checked; LaneDiffView hangs one review bar per block (unified + split) and its arrows walk blocks. - git exclude: entries AgentHub writes for a lane go to the COMMON git dir with the lane's path prefix (server/lib/git-exclude.js, used by router-apply and lane-mcp) — a linked worktree's own info/exclude is never read by git, and a subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree lane or creates a stray .git in a non-repo lane. - codex lanes carry no pipeline: codex-ingest mirrors only idle/running, codex-nudge is removed, agents install writes nothing for codex, the Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and every server start clears leftover codex stage data and the old AGENTS.md stage block. - Changes panel: a whole file opens for reading and enters the editor on a click on its text (caret at the clicked line), not automatically. - macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by scripts/service-manager.js). - Adds the AgentHub introduction slides and their image-embedding script.
This commit is contained in:
@@ -51,7 +51,7 @@ import {
|
||||
UnfoldVertical,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout } from "./LaneDiffView";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||
import { LaneConflictView } from "./LaneConflictView";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import type {
|
||||
@@ -59,7 +59,6 @@ import type {
|
||||
LaneChangeRange,
|
||||
LaneChanges,
|
||||
LaneCommit,
|
||||
LaneDiffHunk,
|
||||
LaneTreeEntry,
|
||||
} from "../../lib/types";
|
||||
|
||||
@@ -454,10 +453,33 @@ export function LaneChangesPanel({
|
||||
// The whole-file editor's text while it is open; null = viewing.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const editWanted = useRef(false);
|
||||
// Where the editor opens: the line clicked in the read view, and that view's
|
||||
// scroll offset, so the text does not jump when the textarea replaces it.
|
||||
const editAt = useRef<{ line: number; scrollTop: number } | null>(null);
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const editing = draft !== null;
|
||||
useEffect(() => {
|
||||
const at = editAt.current;
|
||||
const el = editorRef.current;
|
||||
if (!editing || !at || !el) return;
|
||||
editAt.current = null;
|
||||
const offset = el.value
|
||||
.split("\n")
|
||||
.slice(0, at.line)
|
||||
.reduce((n, l) => n + l.length + 1, 0);
|
||||
el.setSelectionRange(offset, offset);
|
||||
el.focus({ preventScroll: true });
|
||||
el.scrollTop = at.scrollTop;
|
||||
}, [editing]);
|
||||
const startEdit = (line: number, scrollTop: number) => {
|
||||
if (!text?.sha) return;
|
||||
editAt.current = { line, scrollTop };
|
||||
setDraft(text.lines.join("\n"));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!editWanted.current || !text) return;
|
||||
editWanted.current = false;
|
||||
if (text.sha) setDraft(text.lines.join("\n"));
|
||||
if (text.sha) startEdit(0, 0);
|
||||
// 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)));
|
||||
@@ -786,16 +808,22 @@ export function LaneChangesPanel({
|
||||
};
|
||||
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
|
||||
|
||||
// One hunk, matched by its `@@` line 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, hunk: string) => {
|
||||
// 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(lane, action, {
|
||||
path,
|
||||
hunk,
|
||||
hunk: change.hunk.header,
|
||||
block: change.block,
|
||||
context: data?.available ? data.context : undefined,
|
||||
...(action === "discard-hunk" ? { confirm: true } : {}),
|
||||
});
|
||||
@@ -810,13 +838,13 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Stage / Undo on each hunk of a file with unstaged changes. The diff is
|
||||
// Stage / Undo 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 hunkActions = (f: LaneChangeFile) =>
|
||||
range === "work" && f.unstaged && !f.untracked && !f.binary
|
||||
? (hunk: LaneDiffHunk) => {
|
||||
const key = `${f.path}\n${hunk.header}`;
|
||||
? (change: LaneDiffChange) => {
|
||||
const key = `${f.path}\n${change.hunk.header}\n${change.block}`;
|
||||
const armed = armedHunk === key;
|
||||
return (
|
||||
<>
|
||||
@@ -826,7 +854,7 @@ export function LaneChangesPanel({
|
||||
title={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
armed ? void actHunk("discard-hunk", f.path, hunk.header) : setArmedHunk(key)
|
||||
armed ? void actHunk("discard-hunk", f.path, change) : setArmedHunk(key)
|
||||
}
|
||||
className={`${HUNK_CHIP} ${
|
||||
armed
|
||||
@@ -841,7 +869,7 @@ export function LaneChangesPanel({
|
||||
aria-label={t("changes.stageHunk")}
|
||||
title={t("changes.stageHunk")}
|
||||
disabled={busy}
|
||||
onClick={() => void actHunk("stage-hunk", f.path, hunk.header)}
|
||||
onClick={() => void actHunk("stage-hunk", f.path, change)}
|
||||
className={`${HUNK_CHIP} border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35`}
|
||||
>
|
||||
{t("changes.stageHunkShort")}
|
||||
@@ -887,12 +915,6 @@ export function LaneChangesPanel({
|
||||
return next;
|
||||
});
|
||||
|
||||
// Full width edits in place: an editable whole file IS the editor, and
|
||||
// Cancel just puts the saved text back (this effect re-seeds the draft).
|
||||
const editableText = full && mode === "file" && whole && range !== "commit" && text?.sha;
|
||||
useEffect(() => {
|
||||
if (editableText && draft === null && text) setDraft(text.lines.join("\n"));
|
||||
}, [editableText, draft, text]);
|
||||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||||
|
||||
const files = data?.available ? data.files : [];
|
||||
@@ -912,8 +934,8 @@ export function LaneChangesPanel({
|
||||
}
|
||||
}, [data, files, confirmDiscard]);
|
||||
|
||||
// A tree or quick-open pick opens the whole file. Always-on editing (full
|
||||
// width) makes a stray click here the easy way to lose typed work.
|
||||
// A tree or quick-open pick opens the whole file — and, with an edit open,
|
||||
// would drop it, so a dirty draft asks first.
|
||||
const openWhole = (p: string) => {
|
||||
if (dirty && !window.confirm(t("changes.dropEdits"))) return;
|
||||
show(p);
|
||||
@@ -1408,6 +1430,9 @@ export function LaneChangesPanel({
|
||||
/>
|
||||
)}
|
||||
{/* Only the working file is editable: a commit is history. */}
|
||||
{/* The whole file enters the editor by a click on its text;
|
||||
only the diff, which has no whole text to click, keeps
|
||||
an Edit button. */}
|
||||
{range !== "commit" &&
|
||||
(draft === null && whole && text && !text.sha ? (
|
||||
<span
|
||||
@@ -1416,16 +1441,21 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{t(noEditReason(text))}
|
||||
</span>
|
||||
) : draft === null && whole ? (
|
||||
text && (
|
||||
<span className="shrink-0 self-center text-[10px] text-fg-muted">
|
||||
{t("changes.clickToEdit")}
|
||||
</span>
|
||||
)
|
||||
) : draft === null ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-edit"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
// From the diff the file is not loaded yet: the
|
||||
// editor opens as soon as the whole-file read lands.
|
||||
if (whole && text?.sha) setDraft(text.lines.join("\n"));
|
||||
else editWanted.current = true;
|
||||
// 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"
|
||||
@@ -1446,7 +1476,7 @@ export function LaneChangesPanel({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || (full && !dirty)}
|
||||
disabled={busy}
|
||||
onClick={() => setDraft(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"
|
||||
>
|
||||
@@ -1457,6 +1487,7 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
{whole && draft !== null ? (
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
data-testid="lane-file-editor"
|
||||
aria-label={t("changes.editorLabel", { path: openFile })}
|
||||
value={draft}
|
||||
@@ -1478,6 +1509,7 @@ export function LaneChangesPanel({
|
||||
lines={text.lines}
|
||||
truncated={text.truncated}
|
||||
fill
|
||||
onEditAt={range !== "commit" && text.sha && !busy ? startEdit : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
|
||||
Reference in New Issue
Block a user