|
| 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"; |
0 commit comments