Skip to content

Commit b8cb69f

Browse files
committed
feat(composer): per-line accept/reject in Apply view
Collapsible per-line decisions panel beneath the Pierre diff — click the header to override individual added/removed lines before applying. The common case (accept everything) stays one click via the floating bar.
1 parent 673c782 commit b8cb69f

5 files changed

Lines changed: 713 additions & 96 deletions

File tree

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import { PatchDiff } from "@pierre/diffs/react";
2+
import { createPatch } from "diff";
3+
import { Check, ChevronDown, ChevronRight, X as XIcon } from "lucide-react";
4+
import { Notice } from "obsidian";
5+
import React, { useMemo, useState } from "react";
6+
import { cn } from "@/lib/utils";
7+
import { logError } from "@/logger";
8+
import { Button } from "../ui/button";
9+
import {
10+
analyzePatch,
11+
reconstructFromLineDecisions,
12+
type Decision,
13+
type LineChange,
14+
} from "./diffHunks";
15+
import { OBSIDIAN_PIERRE_THEME } from "./pierreTheme";
16+
17+
/**
18+
* Props for {@link LineAcceptRejectRenderer}.
19+
*/
20+
export interface LineAcceptRejectRendererProps {
21+
oldText: string;
22+
newText: string;
23+
path: string;
24+
diffStyle: "split" | "unified";
25+
onAccept: (finalText: string) => void;
26+
onReject: () => void;
27+
}
28+
29+
/** Lookup key for a single line decision: `${hunkIndex}:${lineIndex}`. */
30+
const keyOf = (c: LineChange) => `${c.hunkIndex}:${c.lineIndex}`;
31+
32+
/**
33+
* Per-line accept/reject renderer.
34+
*
35+
* Pierre's `<PatchDiff>` doesn't expose a slot for inline per-line UI, so this
36+
* mode splits the view into two:
37+
*
38+
* - Top half: a read-only Pierre diff of the proposed change, for visual
39+
* context. The user reads it like a code review.
40+
* - Bottom half: a structured list of every added (`+`) and removed (`-`)
41+
* line, each with its own Reject / Accept toggle.
42+
*
43+
* "Accept" on an addition means "yes, insert this line in the result";
44+
* "accept" on a removal means "yes, drop this line from the result." That
45+
* matches what `git add -p` calls "stage this hunk" applied at the line level.
46+
*
47+
* Reconstruction is delegated to {@link reconstructFromLineDecisions}, which
48+
* rewrites each hunk to keep only the user-accepted changes and rebuilds the
49+
* file via {@link import("diff").applyPatch}.
50+
*/
51+
export const LineAcceptRejectRenderer: React.FC<LineAcceptRejectRendererProps> = ({
52+
oldText,
53+
newText,
54+
path,
55+
diffStyle,
56+
onAccept,
57+
onReject,
58+
}) => {
59+
const { parsed, changes } = useMemo(
60+
() => analyzePatch(path, oldText, newText),
61+
[path, oldText, newText]
62+
);
63+
64+
// Full-file unified patch for the read-only visual diff at the top.
65+
const fullPatch = useMemo(
66+
() => createPatch(path, oldText, newText, "", "", { context: 3 }),
67+
[path, oldText, newText]
68+
);
69+
70+
// Map<"hunkIdx:lineIdx", "accept" | "reject">. Unspecified = "accept".
71+
const [decisions, setDecisions] = useState<Map<string, Decision>>(() => new Map());
72+
// Decisions panel is collapsed by default — the common case is "click Apply
73+
// and accept everything." Expanding reveals per-line toggles for finer control.
74+
const [decisionsExpanded, setDecisionsExpanded] = useState(false);
75+
76+
React.useEffect(() => {
77+
setDecisions(new Map());
78+
setDecisionsExpanded(false);
79+
}, [parsed]);
80+
81+
const totalChanges = changes.length;
82+
const acceptedCount = useMemo(() => {
83+
let n = 0;
84+
for (const c of changes) {
85+
if ((decisions.get(keyOf(c)) ?? "accept") === "accept") n++;
86+
}
87+
return n;
88+
}, [decisions, changes]);
89+
90+
const setDecision = (c: LineChange, decision: Decision) => {
91+
setDecisions((prev) => {
92+
const next = new Map(prev);
93+
next.set(keyOf(c), decision);
94+
return next;
95+
});
96+
};
97+
98+
const acceptAll = () =>
99+
setDecisions(new Map(changes.map((c) => [keyOf(c), "accept" as Decision])));
100+
const rejectAll = () =>
101+
setDecisions(new Map(changes.map((c) => [keyOf(c), "reject" as Decision])));
102+
103+
const applyDecisions = () => {
104+
const result = reconstructFromLineDecisions(oldText, parsed, decisions);
105+
if (result == null) {
106+
logError("Failed to reconstruct text from line decisions");
107+
new Notice("Failed to apply selected lines — patch did not match the original file.");
108+
return;
109+
}
110+
onAccept(result);
111+
};
112+
113+
if (totalChanges === 0) {
114+
return (
115+
<div className="copilot-pierre-view tw-relative tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-center tw-text-muted">
116+
<div>No changes to apply.</div>
117+
<Button onClick={onReject} className="tw-mt-4">
118+
Close
119+
</Button>
120+
</div>
121+
);
122+
}
123+
124+
return (
125+
<div className="copilot-pierre-view tw-relative tw-flex tw-min-h-0 tw-flex-1 tw-flex-col">
126+
<div className="tw-flex tw-min-h-0 tw-flex-1 tw-flex-col tw-gap-2 tw-overflow-auto tw-p-2">
127+
{/* Top: read-only visual diff. */}
128+
<div className="tw-rounded-md tw-border tw-border-solid tw-border-border">
129+
<PatchDiff
130+
patch={fullPatch}
131+
disableWorkerPool
132+
options={{
133+
diffStyle,
134+
diffIndicators: "bars",
135+
overflow: "wrap",
136+
// The Apply view already shows the file path + diff stats in
137+
// its own header — Pierre's per-file header would just be a
138+
// redundant second row above the diff.
139+
disableFileHeader: true,
140+
theme: { dark: OBSIDIAN_PIERRE_THEME, light: OBSIDIAN_PIERRE_THEME },
141+
}}
142+
/>
143+
</div>
144+
145+
{/* Bottom: per-line decision list — collapsed by default. Expand to
146+
override individual line decisions; otherwise everything stays at
147+
"accept" and the bottom-bar Apply button applies the full diff. */}
148+
<div className="tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-primary-alt">
149+
<button
150+
type="button"
151+
onClick={() => setDecisionsExpanded((v) => !v)}
152+
className="tw-flex tw-w-full tw-items-center tw-gap-2 tw-border-none tw-bg-transparent tw-px-3 tw-py-2 tw-text-left tw-text-xs tw-font-medium tw-text-muted hover:tw-text-normal"
153+
aria-expanded={decisionsExpanded}
154+
>
155+
{decisionsExpanded ? (
156+
<ChevronDown className="tw-size-3" />
157+
) : (
158+
<ChevronRight className="tw-size-3" />
159+
)}
160+
Decisions ({acceptedCount} of {totalChanges} accepted)
161+
{!decisionsExpanded && (
162+
<span className="tw-text-muted">— click to override per line</span>
163+
)}
164+
</button>
165+
{/* Visible separator between the header button and the per-line list,
166+
only when the list is shown. Rendered as a height-0 div with a
167+
bottom border so the line color tracks --background-modifier-border
168+
under the user's theme. */}
169+
{decisionsExpanded && <div className="tw-border-b tw-border-solid tw-border-border" />}
170+
<ul className={cn("tw-m-0 tw-list-none tw-p-0", !decisionsExpanded && "tw-hidden")}>
171+
{changes.map((c, i) => {
172+
const decision = decisions.get(keyOf(c)) ?? "accept";
173+
const isAdd = c.kind === "+";
174+
return (
175+
<li
176+
key={i}
177+
className={cn(
178+
"tw-flex tw-items-center tw-gap-2 tw-border-b tw-border-solid tw-border-border tw-px-3 tw-py-1.5 [&:last-child]:tw-border-b-transparent",
179+
decision === "reject" && "tw-opacity-50"
180+
)}
181+
>
182+
<span
183+
className={cn(
184+
"tw-inline-flex tw-size-5 tw-flex-none tw-items-center tw-justify-center tw-rounded tw-font-mono tw-text-xs",
185+
isAdd ? "tw-bg-success tw-text-success" : "tw-bg-error tw-text-error"
186+
)}
187+
aria-label={isAdd ? "addition" : "removal"}
188+
>
189+
{isAdd ? "+" : "-"}
190+
</span>
191+
<span className="tw-flex-1 tw-truncate tw-font-mono tw-text-xs">
192+
{c.content || <span className="tw-text-muted">(blank line)</span>}
193+
</span>
194+
<div className="tw-flex tw-flex-none tw-gap-1">
195+
<Button
196+
variant={decision === "reject" ? "destructive" : "ghost"}
197+
size="sm"
198+
onClick={() => setDecision(c, "reject")}
199+
title="Reject this line"
200+
>
201+
<XIcon className="tw-size-3" />
202+
</Button>
203+
<Button
204+
variant={decision === "accept" ? "success" : "ghost"}
205+
size="sm"
206+
onClick={() => setDecision(c, "accept")}
207+
title="Accept this line"
208+
>
209+
<Check className="tw-size-3" />
210+
</Button>
211+
</div>
212+
</li>
213+
);
214+
})}
215+
</ul>
216+
</div>
217+
{/* Spacer reserves room for the floating bottom bar. The bar is ~48px
218+
tall (p-2 + sm button) and sits 16px above the visible bottom, so
219+
it occupies the bottom ~64px of the pane. We pad an extra 32px on
220+
top of that so the last per-line accept/reject row clears the bar
221+
with breathing room — without this, the bar's top edge sits flush
222+
against the final row's controls. */}
223+
<div className="tw-h-24 tw-flex-none" />
224+
</div>
225+
226+
<div className="tw-pointer-events-none tw-absolute tw-inset-x-0 tw-bottom-4 tw-z-popover tw-flex tw-justify-center">
227+
<div className="tw-pointer-events-auto tw-flex tw-items-center tw-gap-2 tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-secondary tw-p-2 tw-shadow-lg">
228+
<span className="tw-px-1 tw-text-xs tw-text-muted">
229+
{acceptedCount} of {totalChanges} accepted
230+
</span>
231+
<Button variant="ghost" size="sm" onClick={rejectAll}>
232+
Reject all
233+
</Button>
234+
<Button variant="ghost" size="sm" onClick={acceptAll}>
235+
Accept all
236+
</Button>
237+
<Button variant="destructive" size="sm" onClick={onReject}>
238+
Cancel
239+
</Button>
240+
<Button variant="success" size="sm" onClick={applyDecisions}>
241+
<Check className="tw-size-4" />
242+
Apply {acceptedCount} {acceptedCount === 1 ? "line" : "lines"}
243+
</Button>
244+
</div>
245+
</div>
246+
</div>
247+
);
248+
};
249+
250+
LineAcceptRejectRenderer.displayName = "LineAcceptRejectRenderer";
Lines changed: 14 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,5 @@
1-
import { PatchDiff } from "@pierre/diffs/react";
2-
import { registerCustomCSSVariableTheme } from "@pierre/diffs";
3-
import { createPatch } from "diff";
4-
import { Check, X as XIcon } from "lucide-react";
5-
import React, { useMemo } from "react";
6-
import { Button } from "../ui/button";
7-
8-
/**
9-
* Register an "obsidian" Shiki theme once at module load. Every syntax-token
10-
* role maps to an Obsidian text variable so headings, bold, links, code, etc.
11-
* inside the diff render in the user's editor colors instead of Pierre's
12-
* stock red/orange/cyan palette. The diff red/green tints come from a
13-
* separate CSS layer (see .copilot-pierre-view in tailwind.css) and are
14-
* unaffected by this theme — they sit on the line background, not the text.
15-
*/
16-
const OBSIDIAN_PIERRE_THEME = "obsidian";
17-
let __pierreThemeRegistered = false;
18-
function ensurePierreThemeRegistered() {
19-
if (__pierreThemeRegistered) return;
20-
registerCustomCSSVariableTheme(OBSIDIAN_PIERRE_THEME, {
21-
foreground: "var(--text-normal)",
22-
background: "transparent",
23-
"token-constant": "var(--color-orange)",
24-
"token-string": "var(--text-normal)",
25-
"token-string-expression": "var(--text-normal)",
26-
"token-comment": "var(--text-muted)",
27-
"token-keyword": "var(--text-normal)",
28-
"token-parameter": "var(--text-normal)",
29-
"token-function": "var(--text-normal)",
30-
"token-punctuation": "var(--text-muted)",
31-
"token-link": "var(--text-accent)",
32-
});
33-
__pierreThemeRegistered = true;
34-
}
35-
ensurePierreThemeRegistered();
1+
import React from "react";
2+
import { LineAcceptRejectRenderer } from "./LineAcceptRejectRenderer";
363

374
/**
385
* Props for {@link PierreRenderer}.
@@ -46,75 +13,26 @@ export interface PierreRendererProps {
4613
path: string;
4714
/** "split" -> side-by-side, "unified" -> stacked. */
4815
diffStyle: "split" | "unified";
49-
/** Apply the proposed text to the file. */
16+
/** Apply the (possibly per-line-edited) text to the file. */
5017
onAccept: (finalText: string) => void;
5118
/** Discard the proposal. */
5219
onReject: () => void;
5320
}
5421

5522
/**
56-
* Renders the Apply view using @pierre/diffs.
23+
* Apply view renderer. Wraps {@link LineAcceptRejectRenderer}, which:
5724
*
58-
* Pierre is a Shiki-based renderer aimed at code review UIs. It produces a
59-
* polished diff with syntax-highlighted markdown but does not manage per-block
60-
* accept/reject state, so this renderer offers only a single top-level
61-
* Accept / Reject pair — accepting writes `newText` verbatim, like the merge
62-
* view does with its right pane.
25+
* - Shows a Pierre-rendered visual diff at the top.
26+
* - Hides a per-line decision panel below it (collapsed by default — click
27+
* the header to expand and override individual added/removed lines).
28+
* - Provides a floating bottom bar with Accept all / Reject all shortcuts
29+
* plus the final Apply button.
6330
*
64-
* The worker pool is disabled because Obsidian's renderer process already
65-
* runs in a single Electron window; spawning Shiki workers there is
66-
* unnecessary overhead.
31+
* The default-collapsed panel means the common "click Apply, take the whole
32+
* diff" workflow is one click; finer control is one extra click away.
6733
*/
68-
export const PierreRenderer: React.FC<PierreRendererProps> = ({
69-
oldText,
70-
newText,
71-
path,
72-
diffStyle,
73-
onAccept,
74-
onReject,
75-
}) => {
76-
const patch = useMemo(
77-
() => createPatch(path, oldText, newText, "", "", { context: 3 }),
78-
[path, oldText, newText]
79-
);
80-
81-
return (
82-
<div className="copilot-pierre-view tw-relative tw-flex tw-h-full tw-flex-col">
83-
{/* Floating Accept/Reject bar, centered in the pane. The row spans the
84-
full width so flexbox can center the pill regardless of its width;
85-
pointer-events are off on the row and back on for the pill itself
86-
so empty space doesn't swallow clicks on the diff below. */}
87-
<div className="tw-pointer-events-none tw-absolute tw-inset-x-0 tw-bottom-4 tw-z-popover tw-flex tw-justify-center">
88-
<div className="tw-pointer-events-auto tw-flex tw-gap-2 tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-secondary tw-p-2 tw-shadow-lg">
89-
<Button variant="destructive" size="sm" onClick={onReject}>
90-
<XIcon className="tw-size-4" />
91-
Reject
92-
</Button>
93-
<Button variant="success" size="sm" onClick={() => onAccept(newText)}>
94-
<Check className="tw-size-4" />
95-
Accept
96-
</Button>
97-
</div>
98-
</div>
99-
<div className="tw-flex-1 tw-overflow-auto tw-p-2">
100-
<PatchDiff
101-
patch={patch}
102-
disableWorkerPool
103-
options={{
104-
diffStyle,
105-
diffIndicators: "bars",
106-
// Wrap long prose lines instead of horizontal scroll. The default
107-
// "scroll" mode truncates markdown paragraphs in side-by-side view.
108-
overflow: "wrap",
109-
// Use the Obsidian-mapped Shiki theme so every syntax token
110-
// (markdown heading, bold, link, code, etc.) renders in the
111-
// user's text-normal color instead of Pierre's stock palette.
112-
theme: { dark: OBSIDIAN_PIERRE_THEME, light: OBSIDIAN_PIERRE_THEME },
113-
}}
114-
/>
115-
</div>
116-
</div>
117-
);
118-
};
34+
export const PierreRenderer: React.FC<PierreRendererProps> = (props) => (
35+
<LineAcceptRejectRenderer {...props} />
36+
);
11937

12038
PierreRenderer.displayName = "PierreRenderer";

0 commit comments

Comments
 (0)